mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 10:09:13 +00:00
Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy
This commit is contained in:
@@ -8,7 +8,7 @@ contact_links:
|
||||
url: https://github.com/esphome/esphome-webserver/issues/new/choose
|
||||
about: Report an issue with the ESPHome web server.
|
||||
- name: Report an issue with the ESPHome Builder / Dashboard
|
||||
url: https://github.com/esphome/dashboard/issues/new/choose
|
||||
url: https://github.com/esphome/device-builder/issues/new/choose
|
||||
about: Report an issue with the ESPHome Builder / Dashboard.
|
||||
- name: Report an issue with the ESPHome API client
|
||||
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
|
||||
|
||||
@@ -182,8 +182,8 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Cap concurrency so this smoke test doesn't hog all the shared runners.
|
||||
max-parallel: 2
|
||||
# Modest cap so this smoke test leaves room on the shared runner pool.
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
|
||||
+22
-61
@@ -65,7 +65,7 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
determine-jobs:
|
||||
@@ -208,69 +208,34 @@ jobs:
|
||||
run: script/ci-suggest-changes
|
||||
if: always()
|
||||
|
||||
pre-commit-ci-lite:
|
||||
name: pre-commit.ci lite
|
||||
lint-format:
|
||||
name: Check lint and formatting
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
# Inlined from esphome/pre-commit-action with a restore-only cache
|
||||
# step: the pre-commit-seed-cache job owns saving this cache, so
|
||||
# pull request runs never write per-PR copies.
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the key pre-commit-seed-cache saves
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Run pre-commit
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
# the cache on dev. Every run would miss and then write a per-pull
|
||||
# request copy, which is what the old seed-cache job existed to
|
||||
# avoid. Building the hooks from scratch takes seconds, so skip it.
|
||||
cache: false
|
||||
env:
|
||||
SKIP: pylint,ci-custom
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
PREK_SKIP: pylint,ci-custom
|
||||
# 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
|
||||
if: always()
|
||||
|
||||
pre-commit-seed-cache:
|
||||
name: Seed pre-commit cache
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
# Saves a dev-scoped pre-commit cache that pull request runs can
|
||||
# restore, since pre-commit.ci lite itself never runs on dev pushes.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
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 pre-commit environments
|
||||
id: cache-pre-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the restore key in pre-commit-ci-lite
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Install pre-commit hook environments
|
||||
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit install-hooks
|
||||
msg: apply automatic formatting fixes
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
@@ -501,7 +466,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1
|
||||
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -551,7 +516,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -742,7 +706,6 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -822,7 +785,6 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -909,7 +871,7 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
|
||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
|
||||
matrix:
|
||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||
steps:
|
||||
@@ -1456,16 +1418,15 @@ jobs:
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-24.04
|
||||
# Listed in the same order the jobs are defined above. Two jobs are
|
||||
# Listed in the same order the jobs are defined above. One job is
|
||||
# deliberately left out: "benchmarks" reports through CodSpeed rather than
|
||||
# this check, and "pre-commit-seed-cache" only populates a cache on pushes
|
||||
# to dev.
|
||||
# this check.
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
- pre-commit-ci-lite
|
||||
- lint-format
|
||||
- pytest
|
||||
- codecov-empty-upload
|
||||
- integration-tests
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
|
||||
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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||
# GitHub App token so the labels, comments and closures come from
|
||||
# esphome[bot] instead of github-actions[bot].
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
@@ -58,19 +58,19 @@ jobs:
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Apply pre-commit auto-fixes
|
||||
- name: Apply prek auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# files. prek exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# SKIP:
|
||||
# PREK_SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
@@ -79,18 +79,18 @@ jobs:
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files || true
|
||||
|
||||
- name: Verify pre-commit clean
|
||||
- name: Verify prek clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# real issue and fails the workflow loudly. Same PREK_SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
|
||||
@@ -133,6 +133,8 @@ CTestTestfile.cmake
|
||||
.gcc-flags.json
|
||||
|
||||
config/
|
||||
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
/.temp-clang-tidy.cpp
|
||||
|
||||
@@ -412,7 +412,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Configuration:**
|
||||
* `pyproject.toml`: Defines the Python project metadata and dependencies.
|
||||
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
|
||||
* `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
|
||||
* `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
|
||||
* **CI/CD Pipeline:** Defined in `.github/workflows`.
|
||||
* **Static Analysis & Development:**
|
||||
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
|
||||
@@ -420,7 +420,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
## 6. Development & Testing Workflow
|
||||
|
||||
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
@@ -493,7 +493,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
4. **Lint:** Run `pre-commit` to ensure code is compliant.
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
|
||||
@@ -288,6 +288,7 @@ esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
esphome/components/libretiny/* @kuba2k2
|
||||
esphome/components/libretiny_pwm/* @kuba2k2
|
||||
@@ -296,6 +297,7 @@ esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
@@ -350,6 +352,7 @@ esphome/components/mlx90393/* @functionpointer
|
||||
esphome/components/mlx90614/* @jesserockz
|
||||
esphome/components/mmc5603/* @benhoff
|
||||
esphome/components/mmc5983/* @agoode
|
||||
esphome/components/modbus_client/* @exciton
|
||||
esphome/components/modbus_controller/* @martgras
|
||||
esphome/components/modbus_controller/binary_sensor/* @martgras
|
||||
esphome/components/modbus_controller/number/* @martgras
|
||||
@@ -438,6 +441,7 @@ esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rp2_ble_tracker/* @bdraco
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
|
||||
+1
-1
@@ -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.9.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+40
-44
@@ -2,16 +2,12 @@
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
@@ -19,16 +15,18 @@ from typing import Protocol
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# 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
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG_TOPIC,
|
||||
@@ -48,6 +46,8 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
SECRETS_FILES,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -55,6 +55,7 @@ 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.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 (
|
||||
@@ -484,8 +485,6 @@ def has_web_server_ota() -> bool:
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
from esphome.components.mqtt import CONF_DISCOVER_IP
|
||||
|
||||
if CONF_MQTT not in CORE.config:
|
||||
return False
|
||||
# Default Enabled
|
||||
@@ -618,6 +617,8 @@ def _resolve_network_devices(
|
||||
|
||||
|
||||
def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
from aioesphomeapi import LogParser
|
||||
import serial
|
||||
|
||||
@@ -630,18 +631,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
return 1
|
||||
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
|
||||
|
||||
process_stacktrace = None
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
backtrace_state = False
|
||||
# Decoder resolution, crash isolation, and disable-after-failure
|
||||
# all live in LogLineProcessor, shared with the API log path.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = baud_rate
|
||||
ser.port = port
|
||||
@@ -681,11 +673,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
"utf8", "backslashreplace"
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
|
||||
if process_stacktrace is not None:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
processor.process_line(line)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -930,9 +918,10 @@ def upload_using_esptool(
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
|
||||
mcu = get_esp32_variant().lower()
|
||||
# Same lookup as esp32.get_esp32_variant(), read directly so the
|
||||
# serial upload path does not import the esp32 package; both the
|
||||
# validator and the warm-cache apply_to_core populate this key.
|
||||
mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
|
||||
|
||||
line_callbacks: list[Callable[[str], str | None]] = []
|
||||
if (
|
||||
@@ -986,6 +975,8 @@ def upload_using_esptool(
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
import shutil
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
@@ -1023,6 +1014,8 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
the mass storage copy approach that causes "disk not ejected properly"
|
||||
warnings on macOS.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
@@ -1129,6 +1122,8 @@ def check_permissions(port: str):
|
||||
"the USB cable can be used for data and is not a power-only cable."
|
||||
)
|
||||
if not (os.access(port, os.R_OK | os.W_OK)):
|
||||
import getpass
|
||||
|
||||
raise EsphomeError(
|
||||
"You do not have read or write permission on the selected serial port. "
|
||||
"To resolve this issue, you can add your user to the dialout group "
|
||||
@@ -1141,12 +1136,11 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.upload_program(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
return 0, host
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
@@ -1406,12 +1400,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
platform_show_logs = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "show_logs"
|
||||
)
|
||||
if platform_show_logs is not None and platform_show_logs(config, args, devices):
|
||||
return 0
|
||||
|
||||
if "logger" not in config:
|
||||
raise EsphomeError("Logger is not configured!")
|
||||
@@ -1429,7 +1422,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
if has_api() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
from esphome.components.api.client import run_logs
|
||||
from esphome.api_client import run_logs
|
||||
|
||||
return run_logs(
|
||||
config,
|
||||
@@ -1713,7 +1706,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
|
||||
from esphome.bundle import ConfigBundleCreator
|
||||
|
||||
creator = ConfigBundleCreator(config)
|
||||
|
||||
@@ -2563,10 +2556,11 @@ def run_esphome(argv):
|
||||
return 0
|
||||
|
||||
# Bundle support: if the configuration is a .esphomebundle, extract it
|
||||
# and rewrite conf_path to the extracted YAML config.
|
||||
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
|
||||
# and rewrite conf_path to the extracted YAML config. The suffix check
|
||||
# stays inline so the ordinary run never imports esphome.bundle.
|
||||
if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
|
||||
from esphome.bundle import prepare_bundle_for_compile
|
||||
|
||||
if is_bundle_path(conf_path):
|
||||
_LOGGER.info("Extracting config bundle %s...", conf_path)
|
||||
conf_path = prepare_bundle_for_compile(conf_path)
|
||||
# Update the argument so downstream code sees the extracted path
|
||||
@@ -2607,6 +2601,8 @@ def run_esphome(argv):
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.util import safe_print
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop."""
|
||||
from datetime import datetime
|
||||
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Decoder resolution policy lives in LogLineProcessor.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(config, addresses, subscribe_states=subscribe_states)
|
||||
)
|
||||
+111
-18
@@ -11,43 +11,136 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from itertools import count
|
||||
import logging
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long the orphan watcher waits for an abandoned coroutine before giving
|
||||
# up, so a hung operation does not park a watcher thread forever.
|
||||
ORPHAN_WAIT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
_runner_ids = count(1)
|
||||
|
||||
|
||||
class AsyncDispatchTimeout(TimeoutError):
|
||||
"""The caller stopped waiting; the coroutine was abandoned.
|
||||
|
||||
A subclass so callers can tell the dispatcher's own expiry apart from a
|
||||
``TimeoutError`` raised inside the coroutine, while existing
|
||||
``except TimeoutError`` handlers keep working.
|
||||
"""
|
||||
|
||||
|
||||
class AsyncThreadRunner[T](threading.Thread):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
``exception`` so ``event`` is always set — this prevents callers waiting
|
||||
on ``event`` from hanging forever when the coroutine crashes.
|
||||
|
||||
Typical usage::
|
||||
|
||||
runner = AsyncThreadRunner(lambda: my_coro(arg))
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout=5.0):
|
||||
... # timed out
|
||||
if runner.exception is not None:
|
||||
raise runner.exception
|
||||
result = runner.result
|
||||
``event`` is always set, even when the coroutine crashes, so waiters
|
||||
never hang; ``completed`` distinguishes a delivered result (even a
|
||||
legitimate ``None``) from a coroutine that never finished. Prefer
|
||||
:func:`run_async`; use this class directly only when a failure should
|
||||
degrade to a default value instead of raising.
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
|
||||
self._coro_factory = coro_factory
|
||||
self.result: T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.completed = False
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
# Distinguishes a delivered result from "never ran", since None
|
||||
# is a valid result value.
|
||||
self.completed = True
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture everything, including BaseException — otherwise a
|
||||
# cancellation or SystemExit would leave ``exception`` unset and
|
||||
# waiters would mistake the empty ``result`` for success.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self._runner())
|
||||
try:
|
||||
asyncio.run(self._runner())
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# asyncio.run itself can fail before _runner executes (e.g. loop
|
||||
# creation under fd exhaustion); record it so waiters never hang.
|
||||
# A failure during loop cleanup after the coroutine completed
|
||||
# must not clobber the delivered result, hence the guard.
|
||||
if self.exception is None and not self.completed:
|
||||
self.exception = exc
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Event loop teardown failed after outcome recorded",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
|
||||
def run_async[T](
|
||||
coro_factory: Callable[[], Awaitable[T]],
|
||||
timeout: float | None = None,
|
||||
on_orphan: Callable[[T], None] | None = None,
|
||||
) -> T:
|
||||
"""Run a coroutine in a daemon-thread event loop and return its result.
|
||||
|
||||
Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
|
||||
within ``timeout`` seconds; the thread is abandoned and exits with the
|
||||
interpreter. If the abandoned coroutine later produces a result,
|
||||
``on_orphan`` (if given) is called with it so resources such as a
|
||||
connected socket can be released; delivery is best effort and bounded
|
||||
by ``ORPHAN_WAIT_TIMEOUT``.
|
||||
"""
|
||||
runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout):
|
||||
|
||||
def _cleanup() -> None:
|
||||
if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
|
||||
# The one state where a resource can genuinely leak; leave
|
||||
# a trace so a recurring hang is attributable.
|
||||
_LOGGER.info(
|
||||
"Orphan watcher gave up after %.0fs; a late result may leak",
|
||||
ORPHAN_WAIT_TIMEOUT,
|
||||
)
|
||||
return
|
||||
if not runner.completed:
|
||||
# The only place an abandoned thread's real error surfaces;
|
||||
# without it a late failure hides behind the TimeoutError.
|
||||
# INFO, not DEBUG: it fires at most once per abandoned
|
||||
# operation and the cause may not reproduce on a rerun.
|
||||
_LOGGER.info(
|
||||
"Abandoned async operation failed",
|
||||
exc_info=runner.exception,
|
||||
)
|
||||
return
|
||||
if (result := runner.result) is None:
|
||||
return
|
||||
if on_orphan is None:
|
||||
_LOGGER.debug("Discarding late result; no on_orphan handler")
|
||||
return
|
||||
try:
|
||||
on_orphan(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# INFO, not DEBUG: a failed release means a real leak, and
|
||||
# it fires at most once per abandoned operation.
|
||||
_LOGGER.info("Error releasing orphaned result", exc_info=True)
|
||||
|
||||
threading.Thread(
|
||||
target=_cleanup, daemon=True, name="async-orphan-cleanup"
|
||||
).start()
|
||||
raise AsyncDispatchTimeout("Timed out waiting for async operation")
|
||||
if (exc := runner.exception) is not None:
|
||||
raise exc
|
||||
if not runner.completed:
|
||||
raise RuntimeError("Async operation finished without a result or an exception")
|
||||
return cast("T", runner.result)
|
||||
|
||||
+37
-6
@@ -20,6 +20,7 @@ from typing import Any
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.const import (
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_ESPHOME,
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_INCLUDES,
|
||||
@@ -29,12 +30,12 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import filter_yaml_files
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
@@ -128,6 +129,9 @@ class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Directories whose YAML files are scanned for !secret references but
|
||||
# never bundled, e.g. git package checkouts the builder re-fetches.
|
||||
secret_scan_dirs: set[Path] = field(default_factory=set)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
@@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None:
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
def add_secret_scan_dir(path: Path) -> None:
|
||||
"""Register a directory to scan for ``!secret`` references when bundling.
|
||||
|
||||
The directory's files are not added to the bundle. Components call this
|
||||
for YAML the build consumes without bundling it — such as git-fetched
|
||||
packages, which the builder re-fetches — so the secrets those files
|
||||
reference are still shipped in the filtered secrets file.
|
||||
|
||||
A relative path is taken as relative to the config directory.
|
||||
"""
|
||||
if not path.is_absolute():
|
||||
path = CORE.relative_config_path(path)
|
||||
_get_data().secret_scan_dirs.add(path)
|
||||
|
||||
|
||||
def _secret_scan_yaml_files() -> list[Path]:
|
||||
"""Return the YAML files inside registered secret-scan directories."""
|
||||
return filter_yaml_files(
|
||||
f
|
||||
for scan_dir in _get_data().secret_scan_dirs
|
||||
for f in yaml_util.find_files(scan_dir, "*")
|
||||
)
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
@@ -310,6 +338,7 @@ class ConfigBundleCreator:
|
||||
yaml_sources = [
|
||||
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
|
||||
]
|
||||
yaml_sources.extend(_secret_scan_yaml_files())
|
||||
used_secret_keys = _find_used_secret_keys(yaml_sources)
|
||||
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
|
||||
|
||||
@@ -394,6 +423,13 @@ class ConfigBundleCreator:
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
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
|
||||
# against the config-dir secrets.yaml at build time, so always
|
||||
# consider that file; filtering no-ops when no key matches.
|
||||
default_secrets = self._config_dir / yaml_util.SECRET_YAML
|
||||
if default_secrets.is_file():
|
||||
self._secrets_paths.add(default_secrets.resolve())
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
@@ -719,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def is_bundle_path(path: Path) -> bool:
|
||||
"""Check if a path looks like a bundle file."""
|
||||
return path.name.lower().endswith(BUNDLE_EXTENSION)
|
||||
|
||||
|
||||
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
"""Add in-memory bytes to a tar archive with deterministic metadata."""
|
||||
info = tarfile.TarInfo(name=name)
|
||||
|
||||
@@ -49,11 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
build_registry_entry,
|
||||
build_registry_list,
|
||||
extract_registry_entry_config,
|
||||
get_slot_count,
|
||||
gpio_pin_expression,
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
slot_counter,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
@@ -61,7 +61,14 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
# Fast path never validates or generates code - no source ranges
|
||||
# needed (see load_yaml). Callers must not feed this config into
|
||||
# read_config/write_cpp: the esp_range consumers in config.py and
|
||||
# cpp_generator.py are isinstance-guarded and would degrade
|
||||
# silently (wrong error/lambda locations) instead of raising.
|
||||
config = yaml_util.load_yaml(
|
||||
cache_path, clear_secrets=False, track_document_range=False
|
||||
)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
@@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
|
||||
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
|
||||
},
|
||||
}
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_BATCH_DELAY = "batch_delay"
|
||||
CONF_CUSTOM_SERVICES = "custom_services"
|
||||
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
|
||||
@@ -443,7 +443,7 @@ void APIConnection::on_disconnect_response() {
|
||||
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
msg.key = entity->get_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
@@ -454,7 +454,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
// Set common fields that are shared by all entity types
|
||||
msg.key = entity->get_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
|
||||
if (entity->has_own_name()) {
|
||||
msg.name = entity->get_name();
|
||||
@@ -1144,7 +1144,7 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = camera::Camera::instance()->get_object_id_hash();
|
||||
msg.key = camera::Camera::instance()->get_entity_key();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
|
||||
@@ -1,177 +1,10 @@
|
||||
from __future__ import annotations
|
||||
"""Backward-compatibility shim; the log client lives in esphome.api_client.
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
Importing this module executes the whole api component package, which pulls
|
||||
in the validation stack. CLI code paths should import esphome.api_client
|
||||
directly so the logs fast path stays light.
|
||||
"""
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
from esphome.api_client import async_run_logs, run_logs
|
||||
|
||||
import contextlib
|
||||
|
||||
from esphome.const import CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.util import safe_print
|
||||
|
||||
from . import CONF_ENCRYPTION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _LogLineProcessor:
|
||||
"""Feeds incoming log lines to the stack-trace decoder.
|
||||
|
||||
Two responsibilities beyond just calling the decoder:
|
||||
1. Catch everything the decoder can raise. aioesphomeapi isolates
|
||||
exceptions raised by log handlers, so an escaping one no longer
|
||||
kills the session, but it does log a full traceback per line. A
|
||||
crash dump carries a PC line plus one per backtrace frame, so the
|
||||
tracebacks bury the dump the user is trying to read. Decoding is a
|
||||
diagnostic nicety; nothing it raises is worth that noise.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out to
|
||||
the toolchain to resolve addr2line, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to retry
|
||||
the failing subprocess for each one. This only works if every
|
||||
failure is caught, which is why 1 is not narrowed to EsphomeError.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
|
||||
self._config = config
|
||||
self._platform_handler = platform_handler
|
||||
self._decode_enabled = True
|
||||
self.backtrace_state = False
|
||||
|
||||
def process_line(self, raw_line: str) -> None:
|
||||
if not self._decode_enabled:
|
||||
return
|
||||
try:
|
||||
if self._platform_handler is not None:
|
||||
self.backtrace_state = self._platform_handler(
|
||||
self._config, raw_line, self.backtrace_state
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
self._decode_enabled = False
|
||||
self.backtrace_state = False
|
||||
# _run_idedata raises EsphomeError with no message; fall back
|
||||
# to a generic explanation when str(exc) is empty.
|
||||
detail = str(exc) or "build artifacts not found locally"
|
||||
_LOGGER.debug("Stack-trace decoding failed", exc_info=True)
|
||||
_LOGGER.warning(
|
||||
"Crash trace decoding unavailable: %s. "
|
||||
"Run 'esphome compile' for this device to enable PC decoding.",
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop."""
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
if len(addresses) == 1:
|
||||
_LOGGER.info("Starting log output from %s using esphome API", addresses[0])
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Try platform-specific stacktrace handler first, fall back to generic
|
||||
platform_process_stacktrace = None
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
processor = _LogLineProcessor(config, platform_process_stacktrace)
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(config, addresses, subscribe_states=subscribe_states)
|
||||
)
|
||||
__all__ = ["async_run_logs", "run_logs"]
|
||||
|
||||
@@ -51,7 +51,11 @@ def _set_core_data(config):
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA
|
||||
# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared
|
||||
# by every LibreTiny platform, and prepending this platform's _set_core_data
|
||||
# onto the shared object would run it for every platform's validation once two
|
||||
# platform modules are imported in one process (device-builder, tests).
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({})
|
||||
|
||||
PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
# Once per registered scan listener; sizes the controller's StaticVector
|
||||
# listener storage.
|
||||
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -179,8 +179,10 @@ void BK72xxBLE::loop() {
|
||||
if (report == nullptr)
|
||||
return;
|
||||
do {
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
for (auto *listener : this->scan_listeners_)
|
||||
listener->on_scan_report(*report);
|
||||
#endif
|
||||
this->report_pool_.release(report);
|
||||
} while ((report = this->report_queue_.pop()) != nullptr);
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
@@ -62,8 +62,12 @@ class BK72xxBLE final : public Component {
|
||||
/// Controller BLE address, least-significant octet first (BLE convention).
|
||||
void get_mac_lsb_first(uint8_t out[6]) const;
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
/// Storage is codegen-sized: the consumer's codegen requests a slot via
|
||||
/// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT.
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
#endif
|
||||
|
||||
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
|
||||
/// Enables the stack first if needed. Returns false on controller failure.
|
||||
@@ -78,7 +82,11 @@ class BK72xxBLE final : public Component {
|
||||
protected:
|
||||
void resolve_mac_();
|
||||
|
||||
std::vector<BLEScanListener *> scan_listeners_;
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiation —
|
||||
// the same StaticVector pattern as the tracker's ble_device_base listeners.
|
||||
StaticVector<BLEScanListener *, BK72XX_BLE_SCAN_LISTENER_COUNT> scan_listeners_;
|
||||
#endif
|
||||
// Report ring: the BDK notice callback (BLE task) allocates a report from the
|
||||
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
|
||||
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
|
||||
|
||||
@@ -12,19 +12,30 @@ Scan modes:
|
||||
Use this when the radio is dedicated to BLE.
|
||||
continuous: false — a started scan runs for `duration` ms, then stops. The
|
||||
FIRST start is external too: nothing in this component
|
||||
starts a non-continuous scan on boot, so until the
|
||||
automation actions land (follow-up PR) the radio stays
|
||||
idle. start_scan() is called from code (e.g. an api
|
||||
client-connected automation) so the single-core radio
|
||||
can service WiFi in between scans.
|
||||
starts a non-continuous scan on boot — the radio stays
|
||||
idle until bk72xx_ble_tracker.start_scan fires (e.g.
|
||||
from an api client-connected automation), so the
|
||||
single-core radio can service WiFi in between scans.
|
||||
"""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import bk72xx_ble, ble_device_base, ota
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.ble_device_base import automation as ble_automation
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.const import (
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_ID,
|
||||
CONF_INTERVAL,
|
||||
CONF_MANUFACTURER_ID,
|
||||
CONF_ON_BLE_ADVERTISE,
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
|
||||
CONF_SERVICE_UUID,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
|
||||
@@ -33,11 +44,21 @@ DEPENDENCIES = ["bk72xx"]
|
||||
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
ble_device_base.register_hub_provider("bk72xx_ble_tracker")
|
||||
|
||||
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
|
||||
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
|
||||
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
|
||||
)
|
||||
|
||||
StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action)
|
||||
StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action)
|
||||
|
||||
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
|
||||
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
|
||||
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
|
||||
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
|
||||
|
||||
|
||||
# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms
|
||||
# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in
|
||||
@@ -49,18 +70,77 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
|
||||
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
|
||||
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
|
||||
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
|
||||
ESPBTAdvertiseTrigger
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEServiceDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEManufacturerDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
|
||||
BLEEndOfScanTrigger
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
# Runs at FINAL priority so every BLE sensor has registered through
|
||||
# ble_device_base (and any tracker-owned listeners have been counted) before
|
||||
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _emit_listener_count() -> None:
|
||||
count = ble_device_base.get_listener_count()
|
||||
if count > 0:
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
|
||||
@automation.register_action(
|
||||
"bk72xx_ble_tracker.start_scan",
|
||||
StartScanAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
|
||||
# Optional with no default, unlike esp32_ble_tracker: omitting it
|
||||
# keeps whatever scan_parameters.continuous configured, instead of
|
||||
# silently forcing one-shot.
|
||||
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def start_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
|
||||
template_ = await cg.templatable(continuous, args, cg.bool_)
|
||||
cg.add(var.set_continuous(template_))
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"bk72xx_ble_tracker.stop_scan",
|
||||
StopScanAction,
|
||||
automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def stop_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
@@ -69,6 +149,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
|
||||
cg.add(var.set_parent(parent))
|
||||
# The tracker registers itself as a controller scan listener in setup();
|
||||
# request the codegen-sized StaticVector slot for it.
|
||||
bk72xx_ble.request_scan_listener_slot()
|
||||
|
||||
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
|
||||
ota.request_ota_state_listeners()
|
||||
@@ -77,6 +160,23 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
|
||||
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
|
||||
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
|
||||
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
|
||||
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
|
||||
|
||||
CORE.add_job(_emit_listener_count)
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
await ble_automation.advertise_trigger_to_code(conf, var)
|
||||
|
||||
for trigger_key, uuid_key, setter_prefix in (
|
||||
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
|
||||
(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_MANUFACTURER_ID,
|
||||
"set_manufacturer_uuid",
|
||||
),
|
||||
):
|
||||
for conf in config.get(trigger_key, []):
|
||||
await ble_automation.uuid_trigger_to_code(
|
||||
conf, var, uuid_key, setter_prefix
|
||||
)
|
||||
|
||||
for conf in config.get(CONF_ON_SCAN_END, []):
|
||||
await ble_automation.scan_end_trigger_to_code(conf, var)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Automation triggers and actions for bk72xx_ble_tracker: triggers are the
|
||||
// neutral ble_device_base classes; only the scan-control actions are
|
||||
// platform-specific.
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
void play(const Ts &...x) override {
|
||||
// With continuous: set, the action wins. Without it, the configured value
|
||||
// is used - stop_scan() clears the runtime flag permanently, so a bare
|
||||
// stop_scan/start_scan pair would otherwise never resume continuous mode.
|
||||
const bool want =
|
||||
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
|
||||
if (this->parent_->scan_running()) {
|
||||
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
|
||||
// the duration window here would let a repeated action keep a one-shot
|
||||
// scan alive forever. A real mode switch re-anchors so a change to
|
||||
// one-shot runs a full duration from now.
|
||||
if (want != this->parent_->scan_continuous()) {
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->restart_scan_duration();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->start_scan();
|
||||
}
|
||||
};
|
||||
|
||||
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->stop_scan(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -171,9 +171,11 @@ void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
|
||||
ble_device_base::ESPBTDevice device;
|
||||
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
bool found = false;
|
||||
for (auto *listener : this->listeners_)
|
||||
if (listener->parse_device(device))
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->parse_device(device)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
|
||||
// it and the scan is one-shot (continuous scans would spam).
|
||||
if (!found && !this->scan_continuous_)
|
||||
@@ -208,6 +210,17 @@ void BK72xxBLETracker::start_scan() {
|
||||
this->try_start_with_backoff_(millis(), /* force= */ true);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::restart_scan_duration() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
// Re-anchor only the one-shot duration clock. scan_period_start_ (the
|
||||
// continuous-mode on_scan_end period) is deliberately left alone: a
|
||||
// start_scan action fired more often than scan_duration_ would otherwise
|
||||
// suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
|
||||
// publish) rides on that period.
|
||||
this->scan_start_time_ = millis();
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan() {
|
||||
this->scan_continuous_ = false;
|
||||
this->scan_requested_ = false; // also cancels a pending (not yet successful) start
|
||||
|
||||
@@ -70,7 +70,22 @@ class BK72xxBLETracker : public Component,
|
||||
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
|
||||
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
|
||||
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
|
||||
/// Set from YAML (scan_parameters.continuous); also the value
|
||||
/// configured_continuous() reports and a bare start_scan action restores.
|
||||
void set_configured_continuous(bool scan_continuous) {
|
||||
this->scan_continuous_ = scan_continuous;
|
||||
this->scan_continuous_configured_ = scan_continuous;
|
||||
}
|
||||
/// Runtime control (esp32_ble_tracker lambda parity): does not change the
|
||||
/// configured value, so configured_continuous() still reports what YAML
|
||||
/// asked for.
|
||||
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
|
||||
bool scan_continuous() const { return this->scan_continuous_; }
|
||||
bool configured_continuous() const { return this->scan_continuous_configured_; }
|
||||
/// Re-anchor the one-shot duration clock of a running scan to now — used
|
||||
/// when an action changes the scan mode without stopping the radio. The
|
||||
/// continuous-mode on_scan_end period is deliberately not touched.
|
||||
void restart_scan_duration();
|
||||
|
||||
// ---- Public scan control ----
|
||||
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
|
||||
@@ -91,7 +106,14 @@ class BK72xxBLETracker : public Component,
|
||||
// controller never solicits scan responses and never merges them; consumers
|
||||
// relying on scan-response fields (device names) get them only where the
|
||||
// receiver merges per address (Home Assistant does). No GATT client either.
|
||||
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
|
||||
// scan_mode_switch stays false for the same reason: with no active-scan
|
||||
// path there is no mode to switch to.
|
||||
return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false};
|
||||
}
|
||||
bool request_scan_mode(bool active) override {
|
||||
// Passive-only controller: a passive request is already honored, an active
|
||||
// one cannot be.
|
||||
return !active;
|
||||
}
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
@@ -127,6 +149,7 @@ class BK72xxBLETracker : public Component,
|
||||
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
|
||||
uint32_t scan_duration_{300000};
|
||||
bool scan_continuous_{true};
|
||||
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
|
||||
bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure
|
||||
|
||||
@@ -9,6 +9,7 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MAC_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_NOTIFY,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_SERVICE_UUID,
|
||||
@@ -16,11 +17,46 @@ from esphome.const import (
|
||||
CONF_VALUE,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client"]
|
||||
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
|
||||
def validate_descriptor_not_notify(config: ConfigType) -> ConfigType:
|
||||
"""Reject descriptor_uuid combined with notify or on_notify.
|
||||
|
||||
BLE descriptors cannot send notifications; only characteristics can, and
|
||||
ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify.
|
||||
"""
|
||||
if CONF_DESCRIPTOR_UUID in config and (
|
||||
config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or "
|
||||
f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove "
|
||||
f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or "
|
||||
f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def notify_from_on_notify(config: ConfigType) -> ConfigType:
|
||||
"""Enable notifications when an on_notify automation is configured.
|
||||
|
||||
The triggers have no registration path of their own; without notify the
|
||||
automation would validate but never fire.
|
||||
"""
|
||||
if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]:
|
||||
config = config.copy()
|
||||
config[CONF_NOTIFY] = True
|
||||
return config
|
||||
|
||||
|
||||
ble_client_ns = cg.esphome_ns.namespace("ble_client")
|
||||
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
|
||||
BLEClientNode = ble_client_ns.class_("BLEClientNode")
|
||||
|
||||
@@ -14,13 +14,16 @@ from esphome.const import (
|
||||
UNIT_DECIBEL_MILLIWATT,
|
||||
)
|
||||
|
||||
from .. import ble_client_ns
|
||||
from .. import (
|
||||
CONF_DESCRIPTOR_UUID,
|
||||
CONF_ON_NOTIFY,
|
||||
ble_client_ns,
|
||||
notify_from_on_notify,
|
||||
validate_descriptor_not_notify,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
TYPE_CHARACTERISTIC = "characteristic"
|
||||
TYPE_RSSI = "rssi"
|
||||
|
||||
@@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
},
|
||||
lower=True,
|
||||
),
|
||||
validate_descriptor_not_notify,
|
||||
notify_from_on_notify,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
break;
|
||||
}
|
||||
this->handle = chr->handle;
|
||||
if (this->descr_uuid_.get_uuid().len > 0) {
|
||||
if (this->descr_uuid_.is_set()) {
|
||||
auto *descr = chr->get_descriptor(this->descr_uuid_);
|
||||
if (descr == nullptr) {
|
||||
this->status_set_warning();
|
||||
|
||||
@@ -9,13 +9,16 @@ from esphome.const import (
|
||||
CONF_TRIGGER_ID,
|
||||
)
|
||||
|
||||
from .. import ble_client_ns
|
||||
from .. import (
|
||||
CONF_DESCRIPTOR_UUID,
|
||||
CONF_ON_NOTIFY,
|
||||
ble_client_ns,
|
||||
notify_from_on_notify,
|
||||
validate_descriptor_not_notify,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
adv_data_t = cg.std_vector.template(cg.uint8)
|
||||
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
|
||||
@@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA),
|
||||
validate_descriptor_not_notify,
|
||||
notify_from_on_notify,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
break;
|
||||
}
|
||||
this->handle = chr->handle;
|
||||
if (this->descr_uuid_.get_uuid().len > 0) {
|
||||
if (this->descr_uuid_.is_set()) {
|
||||
auto *descr = chr->get_descriptor(this->descr_uuid_);
|
||||
if (descr == nullptr) {
|
||||
this->status_set_warning();
|
||||
|
||||
@@ -8,32 +8,43 @@ ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
|
||||
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
|
||||
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
|
||||
subclass, so there is no platform table here and no dependency in either
|
||||
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and
|
||||
calls register_ble_device() in to_code; a tracker component subclasses BLEHub
|
||||
(C++ and codegen class). Adding a new BLE chip requires only a new tracker
|
||||
component.
|
||||
direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an
|
||||
explicit ble_hub_id: is a declared key even on strict schemas) and calls
|
||||
register_ble_device() in to_code; a tracker component subclasses BLEHub (C++
|
||||
and codegen class) and MUST call register_hub_provider() at import time —
|
||||
without it _require_hub rejects configs that bind through the generated id
|
||||
(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip
|
||||
requires only a new in-tree tracker component; out-of-tree BLE hubs are
|
||||
not supported.
|
||||
|
||||
AES-CCM decryption for encrypted advertisements is provided portably in
|
||||
ble_aes_ccm.h.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL
|
||||
from esphome.core import CORE
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_INTERVAL,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE, ID, KEY_CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
CONF_BLE_HUB_ID = "ble_hub_id"
|
||||
|
||||
# CORE.data key: number of parsed-advertisement listeners registered in this
|
||||
# build. Trackers whose codegen sizes storage at compile time (esp32's
|
||||
# StaticVector count define) read it in their final coroutine.
|
||||
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
|
||||
# Number of parsed-advertisement listeners registered in this build; read via
|
||||
# cg.get_slot_count() by esp32_ble_tracker's feature coupling.
|
||||
LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
|
||||
|
||||
|
||||
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
|
||||
|
||||
@@ -45,17 +56,92 @@ BLEHub = ble_device_base_ns.class_("BLEHub")
|
||||
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
|
||||
|
||||
|
||||
def inject_ble_hub(config: ConfigType) -> ConfigType:
|
||||
"""Validator: auto-resolve the configured BLE tracker into the config.
|
||||
# Config keys that provide a BLEHub, registered by each tracker component at
|
||||
# import time (a tracker's module is imported iff it can end up in the build).
|
||||
# Used only to phrase an actionable error when a BLE consumer is configured
|
||||
# without any tracker — the binding itself resolves any BLEHub subclass and
|
||||
# needs no platform table. Out-of-tree BLE hubs are not supported; the
|
||||
# registry and the messages below deal in in-tree trackers only.
|
||||
_HUB_PROVIDERS: set[str] = set()
|
||||
|
||||
Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID +
|
||||
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on
|
||||
any platform; multiple trackers can be disambiguated with an explicit
|
||||
ble_hub_id.
|
||||
"""
|
||||
return cv.Schema(
|
||||
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA
|
||||
)(config)
|
||||
# The in-tree trackers per target platform, so the missing-tracker error names
|
||||
# them even in a fresh process where no tracker module has been imported yet (a
|
||||
# consumer imports only ble_device_base, so the registry is empty exactly in
|
||||
# the most common failure: the tracker was simply forgotten). Filtered by the
|
||||
# current platform so an esp32 config is not told to add a Beken tracker; an
|
||||
# unknown/absent platform falls back to every in-tree name.
|
||||
_IN_TREE_HUB_PROVIDERS: dict[str, str] = {
|
||||
"esp32": "esp32_ble_tracker",
|
||||
"bk72xx": "bk72xx_ble_tracker",
|
||||
"rp2": "rp2_ble_tracker",
|
||||
"ln882x": "ln882h_ble_tracker",
|
||||
}
|
||||
|
||||
|
||||
def register_hub_provider(component: str) -> None:
|
||||
"""Called at import time by every component whose config key declares a BLEHub."""
|
||||
_HUB_PROVIDERS.add(component)
|
||||
|
||||
|
||||
def _require_hub(value: ID) -> ID:
|
||||
# Without this check a missing tracker surfaces at ID resolution as
|
||||
# "Couldn't find any component that can be used for 'ble_device_base::BLEHub'"
|
||||
# — a C++ class name the user never types. Component final validation cannot
|
||||
# phrase it better: the ID pass runs first and its error skips all later
|
||||
# steps. All explicitly configured components are loaded before any schema
|
||||
# validates, so a registered provider in loaded_integrations is exact here.
|
||||
if value.id is not None:
|
||||
# Explicit ble_hub_id: — the user is pointing at a specific hub (the
|
||||
# multi-hub disambiguation case). Let the ID pass judge it; its error
|
||||
# names the missing id, which is accurate.
|
||||
return value
|
||||
if not _HUB_PROVIDERS & CORE.loaded_integrations:
|
||||
# Defensive lookup rather than CORE.target_platform: the property
|
||||
# raises when no platform is registered, and this message must never
|
||||
# be the thing that crashes. In a real run the platform is always set
|
||||
# (LoadTargetPlatformValidationStep runs before any other domain), so
|
||||
# the unfiltered all-platforms fallback is reachable only from tests.
|
||||
platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM)
|
||||
if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS:
|
||||
# Known platform with no in-tree hub (esp8266, host, rtl87xx, …):
|
||||
# listing the other platforms' trackers would misdirect, and
|
||||
# out-of-tree BLE hubs are not supported.
|
||||
raise cv.Invalid(
|
||||
f"No BLE tracker exists for {platform}; BLE components are "
|
||||
"not supported on this platform"
|
||||
)
|
||||
in_tree = (
|
||||
{tracker}
|
||||
if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform))
|
||||
else set(_IN_TREE_HUB_PROVIDERS.values())
|
||||
)
|
||||
# in_tree only: _HUB_PROVIDERS is import-time state that outlives
|
||||
# CORE.reset() in a long-lived process (dashboard), so a tracker from
|
||||
# an earlier build of another platform must not leak into the message.
|
||||
# The gate above is immune — loaded_integrations resets per run.
|
||||
names = ", ".join(sorted(in_tree))
|
||||
raise cv.Invalid(f"No BLE tracker configured — add one of: {names}")
|
||||
return value
|
||||
|
||||
|
||||
# Schema fragment binding a consumer to the configured BLE tracker: extend a
|
||||
# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a
|
||||
# trailing validator after a PREVENT_EXTRA schema would reject the explicit
|
||||
# form before ever running. An omitted id resolves to the single declared
|
||||
# tracker on any platform; multiple trackers are disambiguated with an
|
||||
# explicit ble_hub_id.
|
||||
BLE_DEVICE_SCHEMA = cv.Schema(
|
||||
{cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)}
|
||||
)
|
||||
|
||||
|
||||
def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Transitional alias for the pre-migration binding key: esp32_ble_id ->
|
||||
ble_hub_id. Warns and auto-migrates until removal; every migrated platform
|
||||
prepends this to its CONFIG_SCHEMA so existing configs keep validating."""
|
||||
return cv.rename_key(
|
||||
"esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component
|
||||
)
|
||||
|
||||
|
||||
def request_irk_support() -> None:
|
||||
@@ -64,16 +150,29 @@ def request_irk_support() -> None:
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
|
||||
|
||||
def get_listener_count() -> int:
|
||||
"""Number of parsed listeners registered so far (for tracker codegen)."""
|
||||
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0)
|
||||
# Number of GATT client connection slots in this build; sizes the platform
|
||||
# backend's connection storage.
|
||||
GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT"
|
||||
|
||||
_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
|
||||
|
||||
|
||||
def request_gatt_client() -> None:
|
||||
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
|
||||
claim one connection slot. Called by bluetooth_proxy once per connection
|
||||
it instantiates on a hub platform."""
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
_request_gatt_connection_slot()
|
||||
|
||||
|
||||
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
"""Register `var` as a parsed-advertisement listener on the configured hub."""
|
||||
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
|
||||
cg.add(hub.register_listener(var))
|
||||
CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1
|
||||
_request_listener_slot()
|
||||
return var
|
||||
|
||||
|
||||
@@ -220,3 +319,25 @@ def as_hex_array(value: str) -> cg.RawExpression:
|
||||
|
||||
def as_reversed_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=True)
|
||||
|
||||
|
||||
def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None:
|
||||
"""Emit the width-matched service-UUID setter for a consumer.
|
||||
|
||||
16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte
|
||||
array (BLE wire order). Shared here so every sensor platform dispatches the
|
||||
same way instead of carrying its own if/elif copy.
|
||||
"""
|
||||
if len(service_uuid) == len(BT_UUID16_FORMAT):
|
||||
cg.add(var.set_service_uuid16(as_hex(service_uuid)))
|
||||
elif len(service_uuid) == len(BT_UUID32_FORMAT):
|
||||
cg.add(var.set_service_uuid32(as_hex(service_uuid)))
|
||||
elif len(service_uuid) == len(BT_UUID128_FORMAT):
|
||||
cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid)))
|
||||
else:
|
||||
# bt_uuid restricts lengths to exactly these three formats; if that
|
||||
# ever loosens, fail the build instead of emitting no setter (a
|
||||
# sensor whose match_by_ is unset silently never matches). ValueError,
|
||||
# not cv.Invalid: this runs from to_code, after validation, where
|
||||
# voluptuous errors surface as raw tracebacks.
|
||||
raise ValueError(f"Unsupported UUID format: {service_uuid}")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses
|
||||
// registered on a BLEHub, exposed by each tracker under its own automation
|
||||
// names. parse_device()'s return feeds the "Found device" suppression.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "ble_device.h"
|
||||
#include "ble_hub.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_addresses(std::initializer_list<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()) {
|
||||
return false;
|
||||
}
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
FixedVector<uint64_t> addresses_;
|
||||
};
|
||||
|
||||
// on_ble_service_data_advertise: fires when an advertisement contains service
|
||||
// data for the given UUID. Optional single-MAC filter.
|
||||
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
|
||||
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
|
||||
void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
this->has_address_ = true;
|
||||
}
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (this->has_address_ && device.address_uint64() != this->address_) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &sd : device.get_service_datas()) {
|
||||
if (sd.uuid == this->uuid_) {
|
||||
this->trigger(sd.data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
ESPBTUUID uuid_{};
|
||||
uint64_t address_{0};
|
||||
bool has_address_{false};
|
||||
};
|
||||
|
||||
// on_ble_manufacturer_data_advertise: fires when an advertisement contains
|
||||
// manufacturer data for the given ID. Optional single-MAC filter.
|
||||
class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
|
||||
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
|
||||
void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
this->has_address_ = true;
|
||||
}
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (this->has_address_ && device.address_uint64() != this->address_) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &md : device.get_manufacturer_datas()) {
|
||||
if (md.uuid == this->uuid_) {
|
||||
this->trigger(md.data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
ESPBTUUID uuid_{};
|
||||
uint64_t address_{0};
|
||||
bool has_address_{false};
|
||||
};
|
||||
|
||||
// on_scan_end: fires whenever a scan period ends (duration elapsed or stop
|
||||
// requested). A listener whose on_scan_end() hook fires the trigger — never
|
||||
// claims devices (parse_device always returns false).
|
||||
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override { return false; }
|
||||
void on_scan_end() override { this->trigger(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shared codegen for the neutral BLE advertisement triggers (automation.h)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
BT_UUID16_FORMAT,
|
||||
BT_UUID32_FORMAT,
|
||||
BT_UUID128_FORMAT,
|
||||
LISTENER_COUNT_DEFINE,
|
||||
as_hex,
|
||||
as_reversed_hex_array,
|
||||
ble_device_base_ns,
|
||||
)
|
||||
|
||||
adv_data_t = cg.std_vector.template(cg.uint8)
|
||||
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
|
||||
ESPBTDeviceConstRef = (
|
||||
ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const")
|
||||
)
|
||||
|
||||
ESPBTAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef)
|
||||
)
|
||||
BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref)
|
||||
)
|
||||
BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"BLEManufacturerDataAdvertiseTrigger",
|
||||
automation.Trigger.template(adv_data_t_const_ref),
|
||||
)
|
||||
BLEEndOfScanTrigger = ble_device_base_ns.class_(
|
||||
"BLEEndOfScanTrigger", automation.Trigger.template()
|
||||
)
|
||||
|
||||
# UUID string length -> setter width. 16/32-bit go out as plain hex literals,
|
||||
# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an
|
||||
# impossible length fails as a KeyError instead of silently picking a width
|
||||
# (bt_uuid validation upstream only ever produces these three).
|
||||
_UUID_WIDTHS = {
|
||||
len(BT_UUID16_FORMAT): "16",
|
||||
len(BT_UUID32_FORMAT): "32",
|
||||
len(BT_UUID128_FORMAT): "128",
|
||||
}
|
||||
|
||||
|
||||
def uuid_trigger_schema(
|
||||
trigger_class: MockObjClass, extra: dict[Any, Any] | None = None
|
||||
):
|
||||
"""Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code().
|
||||
|
||||
`extra` carries the required UUID key (a cv marker, so a dict rather than
|
||||
**kwargs); the optional single-mac filter is what uuid_trigger_to_code()
|
||||
reads back.
|
||||
"""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
**(extra or {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def advertise_trigger_schema(trigger_class: MockObjClass):
|
||||
"""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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def scan_end_trigger_schema(trigger_class: MockObjClass):
|
||||
"""on_scan_end schema: id only — pairs with scan_end_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)}
|
||||
)
|
||||
|
||||
|
||||
# Triggers register as ble_device_base listeners in their constructors; count
|
||||
# them where they are created so no backend can undercount the StaticVector
|
||||
# (push_back past capacity drops silently). Shares the define with
|
||||
# register_ble_device() via the core slot-counter factory.
|
||||
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
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]))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_scan_end trigger."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
async def uuid_trigger_to_code(
|
||||
conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str
|
||||
) -> None:
|
||||
"""Build a UUID-filtered advertise trigger.
|
||||
|
||||
The UUID width picks the setter: 16-/32-bit go out as a plain hex literal,
|
||||
128-bit as a reversed byte array (BLE wire order).
|
||||
"""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
uuid = conf[key]
|
||||
width = _UUID_WIDTHS[len(uuid)]
|
||||
value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid)
|
||||
cg.add(getattr(trigger, f"{setter_prefix}{width}")(value))
|
||||
if (mac := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_address(mac.as_hex))
|
||||
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
|
||||
_count_listener()
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "ble_client_state.h"
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
const char *client_state_to_string(ClientState state) {
|
||||
switch (state) {
|
||||
case ClientState::INIT:
|
||||
return "INIT";
|
||||
case ClientState::DISCONNECTING:
|
||||
return "DISCONNECTING";
|
||||
case ClientState::IDLE:
|
||||
return "IDLE";
|
||||
case ClientState::DISCOVERED:
|
||||
return "DISCOVERED";
|
||||
case ClientState::CONNECTING:
|
||||
return "CONNECTING";
|
||||
case ClientState::CONNECTED:
|
||||
return "CONNECTED";
|
||||
case ClientState::ESTABLISHED:
|
||||
return "ESTABLISHED";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,53 @@
|
||||
// ble_client_state.h
|
||||
//
|
||||
// Platform-neutral GATT client connection state types, shared by every
|
||||
// platform's GATT client implementation (esp32_ble_client, bluetooth_connection
|
||||
// backends). Moved here from esp32_ble_tracker, which re-exports them under its
|
||||
// own namespace for backward compatibility.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// ESPHome-private errors for the API's plain-int error fields, outside the
|
||||
/// ATT code range so they cannot be mistaken for spec errors. -1 is
|
||||
/// understood by API clients as "not connected". Shared by every GATT
|
||||
/// client backend.
|
||||
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
|
||||
static constexpr int GATT_ERR_NO_MEMORY = -2;
|
||||
|
||||
enum class ClientState : uint8_t {
|
||||
// Connection is allocated
|
||||
INIT,
|
||||
// Client is disconnecting
|
||||
DISCONNECTING,
|
||||
// Connection is idle, no device detected.
|
||||
IDLE,
|
||||
// Device advertisement found.
|
||||
DISCOVERED,
|
||||
// Connection in progress.
|
||||
CONNECTING,
|
||||
// Initial connection established.
|
||||
CONNECTED,
|
||||
// The client and sub-clients have completed setup.
|
||||
ESTABLISHED,
|
||||
};
|
||||
|
||||
// Helper function to convert ClientState to string
|
||||
const char *client_state_to_string(ClientState state);
|
||||
|
||||
enum class ConnectionType : uint8_t {
|
||||
// The default connection type, we hold all the services in ram
|
||||
// for the duration of the connection.
|
||||
V1,
|
||||
// The client has a cache of the services and mtu so we should not
|
||||
// fetch them again
|
||||
V3_WITH_CACHE,
|
||||
// The client does not need the services and mtu once we send them
|
||||
// so we should wipe them from memory as soon as we send them
|
||||
V3_WITHOUT_CACHE
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -98,6 +99,8 @@ ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
|
||||
|
||||
#ifdef USE_ESP32
|
||||
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
|
||||
if (uuid.len == 0) // the unset sentinel get_uuid() emits
|
||||
return {};
|
||||
if (uuid.len == ESP_UUID_LEN_16)
|
||||
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
|
||||
if (uuid.len == ESP_UUID_LEN_32)
|
||||
@@ -108,6 +111,10 @@ ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
|
||||
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
|
||||
esp_bt_uuid_t ret;
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
ret.len = 0;
|
||||
memset(&ret.uuid, 0, sizeof(ret.uuid));
|
||||
break;
|
||||
case Type::UUID16:
|
||||
ret.len = ESP_UUID_LEN_16;
|
||||
ret.uuid.uuid16 = this->uuid_.uuid16;
|
||||
@@ -129,7 +136,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
this->scan_result_ = &scan_result;
|
||||
// BLEScanResult's bda is most-significant octet first; the neutral ingest
|
||||
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
|
||||
// address_str() then produce exactly the historical esp32 values.
|
||||
// address_str_to() then produce exactly the historical esp32 values.
|
||||
uint8_t mac_lsb_first[6];
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
mac_lsb_first[i] = scan_result.bda[5 - i];
|
||||
@@ -139,7 +146,8 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
#endif // USE_ESP32
|
||||
|
||||
ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
if (this->type_ == Type::UUID128)
|
||||
// Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID.
|
||||
if (this->type_ == Type::UNSET || this->type_ == Type::UUID128)
|
||||
return *this;
|
||||
uint8_t data[16];
|
||||
this->to_128bit_(data);
|
||||
@@ -149,6 +157,8 @@ ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
|
||||
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
return false;
|
||||
case Type::UUID16:
|
||||
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
|
||||
case Type::UUID32:
|
||||
@@ -173,6 +183,9 @@ const char *ESPBTUUID::to_str(char *buf) const {
|
||||
// Identical output format to esp32_ble::ESPBTUUID::to_str.
|
||||
char *pos = buf;
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
memcpy(buf, "None", 5);
|
||||
return buf;
|
||||
case Type::UUID16:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
@@ -207,6 +220,7 @@ const char *ESPBTUUID::to_str(char *buf) const {
|
||||
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
|
||||
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
|
||||
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
|
||||
// Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000.
|
||||
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
if (this->type_ == Type::UUID128) {
|
||||
@@ -223,6 +237,8 @@ void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
|
||||
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
if (this->type_ == other.type_) {
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
return true;
|
||||
case Type::UUID16:
|
||||
return this->uuid_.uuid16 == other.uuid_.uuid16;
|
||||
case Type::UUID32:
|
||||
@@ -232,6 +248,9 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured".
|
||||
if (this->type_ == Type::UNSET || other.type_ == Type::UNSET)
|
||||
return false;
|
||||
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
|
||||
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
|
||||
uint8_t a[16];
|
||||
@@ -247,16 +266,21 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
|
||||
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) {
|
||||
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
|
||||
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
|
||||
// Parity with esp32_ble_tracker: gate on the Apple company ID and length only.
|
||||
// (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior
|
||||
// change; it belongs to a follow-up, not this refactor.)
|
||||
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
|
||||
return {};
|
||||
if (data.data.size() != 23)
|
||||
return {};
|
||||
// Require the iBeacon sub-type/length prefix — stricter than the legacy
|
||||
// esp32 parser, which accepted any 23-byte Apple payload and surfaced
|
||||
// non-iBeacon frames as garbage beacons.
|
||||
if (data.data[0] != 0x02 || data.data[1] != 0x15) {
|
||||
if (prefix_rejected != nullptr)
|
||||
*prefix_rejected = true;
|
||||
return {};
|
||||
}
|
||||
return ESPBLEiBeacon(data.data.data());
|
||||
}
|
||||
|
||||
@@ -264,6 +288,44 @@ optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData
|
||||
// ESPBTDevice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBTDevice::get_ibeacon() const {
|
||||
bool prefix_rejected = false;
|
||||
uint8_t rejected_sub_type = 0;
|
||||
uint8_t rejected_len = 0;
|
||||
for (const auto &it : this->manufacturer_datas_) {
|
||||
bool rejected = false;
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
if (rejected && !prefix_rejected) {
|
||||
prefix_rejected = true;
|
||||
rejected_sub_type = it.data[0];
|
||||
rejected_len = it.data[1];
|
||||
}
|
||||
}
|
||||
if (prefix_rejected) {
|
||||
// Only when no beacon was found at all: these frames were accepted before
|
||||
// the prefix check, so their disappearance must be observable at the
|
||||
// default log level. Throttled so a chatty non-iBeacon Apple advertiser
|
||||
// cannot flood the log; a different address may bypass the shared window
|
||||
// so that advertiser cannot mask the device that actually regressed — but
|
||||
// with a 1 s floor, or two alternating advertisers log every frame.
|
||||
static uint32_t last_log = 0;
|
||||
static uint64_t last_addr = 0;
|
||||
const uint32_t now = millis();
|
||||
const uint64_t addr = this->address_uint64();
|
||||
const uint32_t since = now - last_log;
|
||||
if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) {
|
||||
last_log = now;
|
||||
last_addr = addr;
|
||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)",
|
||||
this->address_str_to(addr_buf), rejected_sub_type, rejected_len);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const char *ESPBTDevice::address_type_str() const {
|
||||
switch (this->address_type_) {
|
||||
case BLE_ADDR_TYPE_PUBLIC:
|
||||
@@ -287,7 +349,8 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
|
||||
this->address_[i] = mac[5 - i];
|
||||
this->address_type_ = addr_type;
|
||||
this->rssi_ = rssi;
|
||||
this->name_.clear();
|
||||
this->name_len_ = 0;
|
||||
this->name_[0] = '\0';
|
||||
this->service_uuids_.clear();
|
||||
this->manufacturer_datas_.clear();
|
||||
this->service_datas_.clear();
|
||||
@@ -303,7 +366,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
|
||||
" Address: %s (%s)\n"
|
||||
" RSSI: %d\n"
|
||||
" Name: '%s'",
|
||||
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str());
|
||||
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_);
|
||||
for (auto &it : this->tx_powers_) {
|
||||
ESP_LOGVV(TAG, " TX Power: %d", it);
|
||||
}
|
||||
@@ -346,6 +409,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
|
||||
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
}
|
||||
|
||||
// Remove before 2027.2.0
|
||||
std::string ESPBTDevice::address_str() const {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->address_str_to(buf));
|
||||
@@ -414,8 +478,12 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
|
||||
// Keep the longest name seen — a merged adv + scan-response frame may carry both the
|
||||
// shortened and the complete name, and the shortened form must never replace the
|
||||
// complete one (same rule as esp32_ble_tracker's parse_adv_).
|
||||
if (ad_data_len > this->name_.length())
|
||||
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len);
|
||||
if (ad_data_len > this->name_len_) {
|
||||
uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast<uint8_t>(ad_data_len);
|
||||
memcpy(this->name_, ad_data, name_len);
|
||||
this->name_[name_len] = '\0';
|
||||
this->name_len_ = name_len;
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x0A: // TX Power Level
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -85,14 +86,17 @@ class ESPBTUUID {
|
||||
bool operator==(const ESPBTUUID &other) const;
|
||||
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
|
||||
|
||||
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf
|
||||
/// (>= UUID_STR_LEN bytes) and return buf.
|
||||
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an
|
||||
/// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf.
|
||||
const char *to_str(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
|
||||
#endif
|
||||
enum class Type : uint8_t { UUID16, UUID32, UUID128 };
|
||||
// UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel).
|
||||
enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 };
|
||||
Type type() const { return this->type_; }
|
||||
/// True if a UUID has been configured (not default-constructed).
|
||||
bool is_set() const { return this->type_ != Type::UNSET; }
|
||||
uint16_t uuid16() const { return this->uuid_.uuid16; }
|
||||
uint32_t uuid32() const { return this->uuid_.uuid32; }
|
||||
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
|
||||
@@ -101,7 +105,7 @@ class ESPBTUUID {
|
||||
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
|
||||
void to_128bit_(uint8_t out[16]) const;
|
||||
|
||||
Type type_{Type::UUID16};
|
||||
Type type_{Type::UNSET};
|
||||
union {
|
||||
uint16_t uuid16;
|
||||
uint32_t uuid32;
|
||||
@@ -126,7 +130,12 @@ class ESPBLEiBeacon {
|
||||
public:
|
||||
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
|
||||
explicit ESPBLEiBeacon(const uint8_t *data);
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
|
||||
/// prefix_rejected: caller must initialise to false; set to true ONLY when a
|
||||
/// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix —
|
||||
/// the case the legacy esp32 parser accepted. Never written on accept or on
|
||||
/// the non-Apple/wrong-size rejects. The caller with the device address does
|
||||
/// the logging (see ESPBTDevice::get_ibeacon()).
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr);
|
||||
|
||||
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
|
||||
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
|
||||
@@ -158,6 +167,13 @@ inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
|
||||
return addr;
|
||||
}
|
||||
|
||||
/// Unpack a uint64 BLE address into printable (MSB-first) byte order —
|
||||
/// the order bd_addr_t / esp_bd_addr_t style APIs expect.
|
||||
inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = (address >> ((5 - i) * 8)) & 0xFF;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice — parsed BLE advertisement
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -172,8 +188,9 @@ class ESPBTDevice {
|
||||
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
|
||||
|
||||
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
|
||||
ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0")
|
||||
std::string address_str() const;
|
||||
/// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf.
|
||||
/// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf.
|
||||
const char *address_str_to(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
|
||||
@@ -189,6 +206,8 @@ class ESPBTDevice {
|
||||
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t.
|
||||
esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
|
||||
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result.
|
||||
/// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending
|
||||
/// consumer feedback on the raw scan-result fields.
|
||||
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
|
||||
// Exposed through a function for use in lambdas
|
||||
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
|
||||
@@ -200,7 +219,9 @@ class ESPBTDevice {
|
||||
const char *address_type_str() const;
|
||||
|
||||
int get_rssi() const { return rssi_; }
|
||||
const std::string &get_name() const { return name_; }
|
||||
/// Advertised name as a view into the fixed buffer (always NUL-terminated,
|
||||
/// so c_str() is safe); converts implicitly to std::string where needed.
|
||||
StringRef get_name() const { return StringRef(this->name_, this->name_len_); }
|
||||
|
||||
const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
|
||||
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
|
||||
@@ -214,22 +235,22 @@ class ESPBTDevice {
|
||||
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
|
||||
bool resolve_irk(const uint8_t *irk) const;
|
||||
|
||||
optional<ESPBLEiBeacon> get_ibeacon() const {
|
||||
for (const auto &it : this->manufacturer_datas_) {
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
optional<ESPBLEiBeacon> get_ibeacon() const;
|
||||
|
||||
protected:
|
||||
void parse_adv_(const uint8_t *payload, uint16_t len);
|
||||
|
||||
// Max name bytes in a legacy advertisement AD element (31-byte PDU minus
|
||||
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
|
||||
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
|
||||
|
||||
uint8_t address_[6]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
std::string name_{};
|
||||
// Fixed buffer instead of std::string: no per-advertisement heap churn on
|
||||
// the scan path, and no libstdc++ string/exception machinery in the image.
|
||||
char name_[MAX_ADV_NAME_LEN + 1]{};
|
||||
uint8_t name_len_{0};
|
||||
std::vector<ESPBTUUID> service_uuids_{};
|
||||
std::vector<ServiceData> manufacturer_datas_{};
|
||||
std::vector<ServiceData> service_datas_{};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// ble_gatt_client.h
|
||||
//
|
||||
// Platform-neutral GATT client connection contract.
|
||||
//
|
||||
// A platform's GATT client backend (bluetooth_connection/esp32,
|
||||
// bluetooth_connection/rp2) implements BLEGattConnection; consumers
|
||||
// (bluetooth_proxy) drive it through this interface and receive
|
||||
// completions through GattClientEventListener. All listener callbacks are
|
||||
// delivered on the ESPHome main loop; borrowed data pointers are valid only
|
||||
// for the duration of the call.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// 0 success
|
||||
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
|
||||
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
|
||||
// ESP_FAIL from the stack shares this value; both read as a
|
||||
// failed, unusable connection on the client side)
|
||||
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
|
||||
// anything else: platform stack error/status code, surfaced opaquely.
|
||||
// Connection events carry HCI status/disconnect reason codes (same code
|
||||
// space on every controller).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "ble_client_state.h"
|
||||
#include "ble_device.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
// Materialized GATT database of a connected peer, discovered by the backend
|
||||
// and streamed to the API by the consumer. Flat arrays with index ranges
|
||||
// (not pointers): a service owns characteristics
|
||||
// [first_characteristic, first_characteristic + characteristic_count) and a
|
||||
// characteristic owns descriptors [first_descriptor, ...) — discovery is
|
||||
// depth-first, so the ranges are naturally contiguous.
|
||||
struct GattDescriptor {
|
||||
ESPBTUUID uuid;
|
||||
uint16_t handle;
|
||||
};
|
||||
|
||||
struct GattCharacteristic {
|
||||
ESPBTUUID uuid;
|
||||
uint16_t value_handle;
|
||||
// Needed to rebuild the stack's characteristic object for CCCD operations.
|
||||
uint16_t end_handle;
|
||||
uint8_t properties; // Bluetooth spec property bitfield
|
||||
uint16_t first_descriptor;
|
||||
uint16_t descriptor_count;
|
||||
};
|
||||
|
||||
struct GattService {
|
||||
ESPBTUUID uuid;
|
||||
uint16_t start_handle;
|
||||
uint16_t end_handle;
|
||||
uint16_t first_characteristic;
|
||||
uint16_t characteristic_count;
|
||||
};
|
||||
|
||||
/// Borrowed view of the backend-owned service table. Valid from a successful
|
||||
/// on_service_discovery_done() until release_services(). Characteristics and
|
||||
/// descriptors are reached through the per-service/per-characteristic index
|
||||
/// ranges; the array totals let a consumer bounds-check those ranges instead
|
||||
/// of trusting the backend's discovery bookkeeping blindly.
|
||||
struct GattServiceTable {
|
||||
const GattService *services{nullptr};
|
||||
const GattCharacteristic *characteristics{nullptr};
|
||||
const GattDescriptor *descriptors{nullptr};
|
||||
uint16_t service_count{0};
|
||||
uint16_t characteristic_count{0};
|
||||
uint16_t descriptor_count{0};
|
||||
};
|
||||
|
||||
/// Completion/event sink for a GATT connection. Implemented by the consumer
|
||||
/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop.
|
||||
class GattClientEventListener {
|
||||
public:
|
||||
virtual ~GattClientEventListener() = default;
|
||||
|
||||
/// Connected (with negotiated MTU) or disconnected/connect-failed
|
||||
/// (error = HCI status or disconnect reason).
|
||||
virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0;
|
||||
/// Service discovery finished; on success the service table is populated.
|
||||
virtual void on_service_discovery_done(int error) = 0;
|
||||
/// Characteristic or descriptor read finished. data/len valid during the call.
|
||||
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0;
|
||||
/// Characteristic write-with-response or descriptor write finished.
|
||||
virtual void on_write_result(uint16_t handle, int error) = 0;
|
||||
/// Notification/indication registration state changed.
|
||||
virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0;
|
||||
/// Notification/indication data from the peer. data/len valid during the call.
|
||||
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
|
||||
};
|
||||
|
||||
/// One GATT client connection slot. Operations return 0 when accepted
|
||||
/// (completion arrives via the listener) or a synchronous error code
|
||||
/// (busy, not connected, stack rejection). One operation may be outstanding
|
||||
/// at a time; callers see a synchronous error otherwise.
|
||||
class BLEGattConnection {
|
||||
public:
|
||||
virtual ~BLEGattConnection() = default;
|
||||
|
||||
void set_listener(GattClientEventListener *listener) { this->listener_ = listener; }
|
||||
|
||||
/// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant
|
||||
/// (ble_device.h). Completion: on_connection_state().
|
||||
virtual int connect(uint64_t address, uint8_t addr_type) = 0;
|
||||
/// Disconnect (or cancel a connect in progress). Completion: on_connection_state().
|
||||
virtual int disconnect() = 0;
|
||||
/// Discover the peer's services/characteristics/descriptors into the
|
||||
/// service table. Completion: on_service_discovery_done().
|
||||
virtual int discover_services() = 0;
|
||||
virtual int read_characteristic(uint16_t handle) = 0;
|
||||
virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0;
|
||||
virtual int read_descriptor(uint16_t handle) = 0;
|
||||
virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
|
||||
/// Enable/disable delivery of on_notify_data() for a characteristic value
|
||||
/// handle. Local registration only — the CCCD write is the API client's
|
||||
/// responsibility (it arrives as a plain write_descriptor).
|
||||
virtual int notify_characteristic(uint16_t handle, bool enable) = 0;
|
||||
virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
|
||||
uint16_t timeout) = 0;
|
||||
|
||||
/// Backend-owned service table (see GattServiceTable lifetime).
|
||||
virtual GattServiceTable get_service_table() = 0;
|
||||
/// Free the transient service table storage. Call after streaming.
|
||||
virtual void release_services() = 0;
|
||||
|
||||
protected:
|
||||
GattClientEventListener *listener_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -56,9 +56,15 @@ struct HubCapabilities {
|
||||
/// frame. When false, consumers relying on scan-response fields (e.g. names)
|
||||
/// may only see them where the receiver merges per address (Home Assistant does).
|
||||
bool merges_scan_response;
|
||||
/// GATT client connections are available (today: esp32 only, but a chip SDK
|
||||
/// gaining GATT support only has to flip this bit).
|
||||
/// GATT client connections are available: the platform has a
|
||||
/// bluetooth_connection backend implementing ble_device_base::BLEGattConnection
|
||||
/// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend.
|
||||
bool gatt;
|
||||
/// request_scan_mode() is honored at runtime. Distinct from active_scan:
|
||||
/// a passive-only controller (bk72xx) can never switch, and a hub may
|
||||
/// support active scanning yet still refuse the runtime switch
|
||||
/// (esp32_ble_tracker drives its mode through its own tracker API).
|
||||
bool scan_mode_switch;
|
||||
};
|
||||
|
||||
class BLEHub {
|
||||
@@ -79,6 +85,17 @@ class BLEHub {
|
||||
virtual bool scan_running() = 0;
|
||||
/// True when the current/configured scan mode is active (scan requests sent).
|
||||
virtual bool scan_active() = 0;
|
||||
/// Request a scan-mode change (active = send scan requests). Returns false
|
||||
/// when the hub cannot honor the request; the caller reports the real state
|
||||
/// back to its subscriber. A hub that returns true applies the mode
|
||||
/// immediately: a running scan is restarted with the new mode, an idle one
|
||||
/// picks it up on its next start. The default cannot-change keeps hubs
|
||||
/// without a mode switch (and out-of-tree trackers) building unchanged.
|
||||
/// Independent of HubCapabilities::active_scan: that bit describes what the
|
||||
/// CONTROLLER can do; whether this method honors requests is advertised by
|
||||
/// HubCapabilities::scan_mode_switch, so consumers can gate features on the
|
||||
/// switch without probing.
|
||||
virtual bool request_scan_mode(bool active) { return false; }
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
@@ -23,7 +23,7 @@ class BLEScanner final : public text_sensor::TextSensor,
|
||||
// Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this
|
||||
// sensor has always published.
|
||||
char escaped_name[128];
|
||||
json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false);
|
||||
json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false);
|
||||
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
from esphome.components import ble_device_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ACTIVE, CONF_ID
|
||||
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
|
||||
from esphome.core import CORE
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"]
|
||||
DEPENDENCIES = ["api", "esp32"]
|
||||
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
|
||||
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
|
||||
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
|
||||
# as a side effect, and a module-scope import would leak them into every
|
||||
# platform's registry the moment a config declares `bluetooth_proxy:` —
|
||||
# degrading "Unable to find action" config errors into C++ compile failures.
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
|
||||
"""Components to auto-load for the platform being compiled.
|
||||
|
||||
Callable with no argument so tooling that resolves AUTO_LOAD without a
|
||||
target platform (the device-builder catalog sync does exactly this) gets
|
||||
the union of every arm instead of an empty list — which is what lets it
|
||||
keep cross-referencing the esp32 BLE stack. A real build always has a
|
||||
target platform set, so it takes one of the concrete branches.
|
||||
"""
|
||||
if CORE.is_esp32:
|
||||
return ["esp32_ble_client", "esp32_ble_tracker"]
|
||||
if CORE.target_platform in _HUB_PLATFORMS:
|
||||
return ["ble_device_base"]
|
||||
# No target platform, or one this component does not support: tooling
|
||||
# resolving the manifest (including the host-pinned dependency resolver) —
|
||||
# expose every arm so the closure keeps the esp32 BLE stack.
|
||||
return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"]
|
||||
|
||||
|
||||
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
|
||||
# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT
|
||||
# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home
|
||||
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
|
||||
# proxy would be misdriven — bk72xx follows once the API carries a feature
|
||||
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
|
||||
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
|
||||
|
||||
DEPENDENCIES = ["api"]
|
||||
CODEOWNERS = ["@jesserockz", "@bdraco"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -20,65 +56,209 @@ DEFAULT_CONNECTION_SLOTS = 3
|
||||
|
||||
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
|
||||
|
||||
BluetoothProxy = bluetooth_proxy_ns.class_(
|
||||
"BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
|
||||
)
|
||||
BluetoothConnection = bluetooth_proxy_ns.class_(
|
||||
"BluetoothConnection", esp32_ble_client.BLEClientBase
|
||||
)
|
||||
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
|
||||
|
||||
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BluetoothConnection),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable
|
||||
# CONFIG_SCHEMA below can state the connection_slots range without importing the
|
||||
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
|
||||
_IDF_MAX_CONNECTIONS = 9
|
||||
|
||||
|
||||
def validate_connections(config):
|
||||
if CONF_CONNECTIONS in config:
|
||||
if not config[CONF_ACTIVE]:
|
||||
raise cv.Invalid(
|
||||
"Connections can only be used if the proxy is set to active"
|
||||
)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
@functools.cache
|
||||
def _esp32_config_schema() -> cv.All:
|
||||
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
|
||||
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
|
||||
raise cv.Invalid(
|
||||
f"bluetooth_proxy's connection-slot limit mirror "
|
||||
f"({_IDF_MAX_CONNECTIONS}) is out of sync with "
|
||||
f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); "
|
||||
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
|
||||
)
|
||||
|
||||
BluetoothConnection = bluetooth_proxy_ns.class_(
|
||||
"BluetoothConnection", esp32_ble_client.BLEClientBase
|
||||
)
|
||||
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BluetoothConnection),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
def validate_connections(config):
|
||||
if CONF_CONNECTIONS in config:
|
||||
if not config[CONF_ACTIVE]:
|
||||
raise cv.Invalid(
|
||||
"Connections can only be used if the proxy is set to active"
|
||||
)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
config
|
||||
)
|
||||
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [
|
||||
CONNECTION_SCHEMA({}) for _ in range(connection_slots)
|
||||
],
|
||||
}
|
||||
return config
|
||||
|
||||
return cv.All(
|
||||
(
|
||||
cv.Schema(
|
||||
{
|
||||
**_COMMON_SCHEMA_KEYS,
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_CONNECTION_SLOTS,
|
||||
default=DEFAULT_CONNECTION_SLOTS,
|
||||
): cv.All(
|
||||
cv.positive_int,
|
||||
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
cv.Optional(CONF_CONNECTIONS): cv.All(
|
||||
cv.ensure_list(CONNECTION_SCHEMA),
|
||||
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
),
|
||||
validate_connections,
|
||||
)
|
||||
|
||||
|
||||
def _validate_no_active(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_ACTIVE]:
|
||||
raise cv.Invalid(
|
||||
"Active connections are not supported on this platform; the proxy "
|
||||
"forwards advertisements only (set active: false)"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BluetoothProxy),
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_CONNECTION_SLOTS,
|
||||
default=DEFAULT_CONNECTION_SLOTS,
|
||||
): cv.All(
|
||||
cv.positive_int,
|
||||
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
cv.Optional(CONF_CONNECTIONS): cv.All(
|
||||
cv.ensure_list(CONNECTION_SCHEMA),
|
||||
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
),
|
||||
validate_connections,
|
||||
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
|
||||
# callback feeds the same API batching. GATT/active connections are excluded at
|
||||
# compile time — only the esp32 build compiles the connection stack; nothing
|
||||
# reads HubCapabilities::gatt at runtime for this today.
|
||||
# Keys both platform schemas must declare identically; each arm spreads this
|
||||
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
|
||||
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
|
||||
_COMMON_SCHEMA_KEYS = {
|
||||
cv.GenerateID(): cv.declare_id(BluetoothProxy),
|
||||
}
|
||||
|
||||
_BLE_HUB_CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
**_COMMON_SCHEMA_KEYS,
|
||||
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
|
||||
# after a strict schema rejects an explicit `ble_hub_id` before it
|
||||
# runs, and that key is the documented way to disambiguate once a
|
||||
# platform has two trackers.
|
||||
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
|
||||
ble_device_base.BLEHub
|
||||
),
|
||||
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_no_active,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
@schema_extractor("schema")
|
||||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
"""Apply the schema for the platform actually being compiled.
|
||||
|
||||
esp32 keeps the full GATT proxy; every other platform gets the
|
||||
advertisement-only shape, which rejects the connection-oriented options
|
||||
above because its schema does not define them.
|
||||
"""
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# The language-schema dumper runs without a platform. Expose the esp32
|
||||
# shape so `connections`, the ids and every default stay in the
|
||||
# generated schema the editor and dashboard consume.
|
||||
return _esp32_config_schema()
|
||||
if CORE.is_esp32:
|
||||
return _esp32_config_schema()(config)
|
||||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||||
# Fail here with the actual reason. Without this gate the error surfaces
|
||||
# later as an unresolvable hub ID ("Are you missing a hub declaration?")
|
||||
# on platforms where no hub component can be declared.
|
||||
raise cv.Invalid(
|
||||
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
|
||||
"active-scan-capable BLE tracker hub is available for this "
|
||||
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
|
||||
"families (advertisement-only)."
|
||||
)
|
||||
return _BLE_HUB_CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
|
||||
"""Reject connection-oriented options by name on hub-only platforms.
|
||||
|
||||
Runs before the walkable schema below so the user gets "this option does
|
||||
not exist here" instead of the option's esp32 value range (which would
|
||||
imply a smaller number is accepted).
|
||||
"""
|
||||
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
|
||||
return config
|
||||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||||
# No proxy of any kind exists here: fall through so _validate_platform
|
||||
# reports "not supported on {platform}" instead of a key-level message
|
||||
# implying an advertisement-only proxy is available.
|
||||
return config
|
||||
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
|
||||
if key in config:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' requires active connection support, which needs the "
|
||||
"esp32 GATT stack; this platform runs the advertisement-only "
|
||||
"proxy and has no such option",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's
|
||||
# field-range extractor among others) introspects it to discover options and
|
||||
# their bounds, which a bare dispatch function would hide. It carries the scalar
|
||||
# keys with no defaults; _validate_platform then runs the real per-platform
|
||||
# schema, which applies the defaults and rejects options the platform does not
|
||||
# support.
|
||||
#
|
||||
# It deliberately does NOT declare `connections`: this outer schema runs before
|
||||
# the per-platform one, so any key it transforms is transformed twice. Running
|
||||
# CONNECTION_SCHEMA twice re-validates an already-generated ID through
|
||||
# declare_id(), which (unlike use_id) has no guard for an ID instance and
|
||||
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
|
||||
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
_reject_connection_keys_off_esp32,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ACTIVE): cv.boolean,
|
||||
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
|
||||
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
|
||||
cv.positive_int,
|
||||
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
),
|
||||
_validate_platform,
|
||||
)
|
||||
|
||||
|
||||
async def _to_code_esp32(config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP)
|
||||
|
||||
@@ -87,17 +267,12 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_active(config[CONF_ACTIVE]))
|
||||
await esp32_ble_tracker.register_raw_ble_device(var, config)
|
||||
await esp32_ble_tracker.register_scanner_state_listener(var, config)
|
||||
|
||||
# Define max connections for protobuf fixed array
|
||||
connection_count = len(config.get(CONF_CONNECTIONS, []))
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
|
||||
|
||||
# Define batch size for BLE advertisements
|
||||
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
|
||||
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
|
||||
# This achieves ~97% WiFi MTU utilization while staying under the limit
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
|
||||
for connection_conf in config.get(CONF_CONNECTIONS, []):
|
||||
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
await cg.register_component(connection_var, connection_conf)
|
||||
@@ -107,4 +282,30 @@ async def to_code(config):
|
||||
if config.get(CONF_CACHE_SERVICES):
|
||||
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
|
||||
|
||||
|
||||
async def _to_code_ble_hub(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_active(config[CONF_ACTIVE]))
|
||||
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
|
||||
cg.add(var.set_ble_hub(hub))
|
||||
|
||||
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
|
||||
# this define whenever a proxy is present; no connections off-esp32.
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if CORE.is_esp32:
|
||||
await _to_code_esp32(config)
|
||||
else:
|
||||
await _to_code_ble_hub(config)
|
||||
|
||||
# Define batch size for BLE advertisements
|
||||
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
|
||||
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
|
||||
# This achieves ~97% WiFi MTU utilization while staying under the limit
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
|
||||
cg.add_define("USE_BLUETOOTH_PROXY")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "bluetooth_proxy.h"
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -8,8 +11,6 @@
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::bluetooth_proxy {
|
||||
|
||||
static const char *const TAG = "bluetooth_proxy";
|
||||
@@ -23,14 +24,14 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
|
||||
|
||||
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
|
||||
// Capture the configured scan mode from YAML before any API changes
|
||||
this->configured_scan_active_ = this->parent_->get_scan_active();
|
||||
|
||||
this->parent_->add_scanner_state_listener(this);
|
||||
}
|
||||
|
||||
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
|
||||
@@ -50,6 +51,62 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#else // !USE_ESP32
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
this->connections_free_response_.limit = 0;
|
||||
this->connections_free_response_.free = 0;
|
||||
|
||||
// Capture the configured scan mode from YAML before any API changes
|
||||
this->configured_scan_active_ = this->hub_->scan_active();
|
||||
this->last_scan_running_ = this->hub_->scan_running();
|
||||
|
||||
// The hub delivers raw advertisements on the ESPHome main loop:
|
||||
// mac is least-significant octet first (BLE controller convention).
|
||||
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
|
||||
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
|
||||
}});
|
||||
}
|
||||
|
||||
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
|
||||
return;
|
||||
|
||||
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
|
||||
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
|
||||
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
|
||||
adv.rssi = raw.rssi;
|
||||
adv.address_type = raw.addr_type;
|
||||
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
|
||||
adv.data_len = length;
|
||||
std::memcpy(adv.data, raw.data, length);
|
||||
|
||||
this->response_.advertisements_len++;
|
||||
|
||||
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
|
||||
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
|
||||
|
||||
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
|
||||
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_bluetooth_scanner_state_() {
|
||||
api::BluetoothScannerStateResponse resp;
|
||||
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
|
||||
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
|
||||
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
|
||||
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
|
||||
resp.configured_mode = this->configured_scan_active_
|
||||
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
|
||||
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_ESP32
|
||||
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
|
||||
connection->address_str(), espbt::client_state_to_string(state));
|
||||
@@ -58,6 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
|
||||
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
|
||||
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
|
||||
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
|
||||
@@ -69,6 +127,8 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl
|
||||
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
// This method should never be called since bluetooth_proxy always uses raw advertisements
|
||||
@@ -109,18 +169,38 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results,
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::log_advertisement_flush_() {
|
||||
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
}
|
||||
|
||||
void BluetoothProxy::dump_config() {
|
||||
#ifdef USE_ESP32
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Active: %s\n"
|
||||
" Connections: %d",
|
||||
YESNO(this->active_), this->connection_count_);
|
||||
#else
|
||||
// Advertisement-only: print configured facts. dump_config runs right after
|
||||
// setup, before the radio is up, so live scan state would always read
|
||||
// "stopped" here — the loop's BluetoothScannerStateResponse carries the
|
||||
// changing value instead.
|
||||
char mac_str[18];
|
||||
this->get_bluetooth_mac_address_pretty(mac_str);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Mode: advertisement-only (no GATT connections)\n"
|
||||
" Configured scan: %s\n"
|
||||
" Adapter MAC: %s",
|
||||
this->configured_scan_active_ ? "active" : "passive",
|
||||
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
// Run advertisement flush / connection cleanup every 100ms
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
@@ -254,13 +334,8 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
esp_bd_addr_t address;
|
||||
uint64_to_bd_addr(msg.address, address);
|
||||
esp_err_t ret = esp_ble_gattc_cache_clean(address);
|
||||
api::BluetoothDeviceClearCacheResponse call;
|
||||
call.address = msg.address;
|
||||
call.success = ret == ESP_OK;
|
||||
call.error = ret;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
|
||||
// Shares the sender with the neutral path, which also null-checks api_connection_.
|
||||
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
|
||||
break;
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
|
||||
@@ -378,6 +453,124 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->parent_->get_scan_active() == active) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
this->parent_->set_scan_active(active);
|
||||
this->parent_->stop_scan();
|
||||
this->parent_->set_scan_continuous(
|
||||
true); // Set this to true to automatically start scanning again when it has cleaned up.
|
||||
}
|
||||
|
||||
#else // !USE_ESP32
|
||||
|
||||
// Advertisement-only proxy. GATT client connections are excluded at compile
|
||||
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
|
||||
// HubCapabilities at runtime today — so every connection-oriented request is
|
||||
// answered with a clean error instead of silence, and Home Assistant treats
|
||||
// the proxy as passive.
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
// Run advertisement flush / scanner-state poll every 100ms
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_advertisement_flush_time_ < 100)
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
|
||||
return;
|
||||
|
||||
// The hub has no scanner-state listener interface; poll and report on change.
|
||||
bool running = this->hub_->scan_running();
|
||||
if (running != this->last_scan_running_) {
|
||||
this->last_scan_running_ = running;
|
||||
this->send_bluetooth_scanner_state_();
|
||||
}
|
||||
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
switch (msg.request_type) {
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
|
||||
ESP_LOGW(TAG, "Active connections are not supported on this platform");
|
||||
this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
|
||||
// Not an error: the device is already disconnected, which is the requested state.
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->send_connections_free();
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
|
||||
this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
|
||||
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
|
||||
this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
resp.address = msg.address;
|
||||
resp.error = ESP_GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->hub_->scan_active() != active) {
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
if (!this->hub_->request_scan_mode(active)) {
|
||||
// Passive-only controller asked for active scanning; the state report
|
||||
// below carries the real, unchanged mode so the subscriber does not
|
||||
// assume the change happened.
|
||||
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
|
||||
}
|
||||
}
|
||||
if (this->api_connection_ != nullptr) {
|
||||
// Keep loop()'s change detector in step with the state sent here, so a
|
||||
// failed restart (scan_running_ dropped by the tracker) is not reported
|
||||
// twice — once now and again on the next tick.
|
||||
this->last_scan_running_ = this->hub_->scan_running();
|
||||
this->send_bluetooth_scanner_state_();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
|
||||
// A previous subscriber still holds the slot. This is almost always a stale
|
||||
@@ -392,9 +585,13 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
|
||||
this->api_connection_->get_peername_to(old_peername));
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
#ifdef USE_ESP32
|
||||
this->parent_->recalculate_advertisement_parser_types();
|
||||
|
||||
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
|
||||
#else
|
||||
this->last_scan_running_ = this->hub_->scan_running();
|
||||
this->send_bluetooth_scanner_state_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) {
|
||||
@@ -403,10 +600,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
#ifdef USE_ESP32
|
||||
this->parent_->recalculate_advertisement_parser_types();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDeviceConnectionResponse call;
|
||||
@@ -434,7 +633,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) {
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTErrorResponse call;
|
||||
@@ -444,7 +643,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDevicePairingResponse call;
|
||||
@@ -455,7 +654,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDeviceUnpairingResponse call;
|
||||
@@ -466,19 +665,21 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->parent_->get_scan_active() == active) {
|
||||
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
|
||||
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
|
||||
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
this->parent_->set_scan_active(active);
|
||||
this->parent_->stop_scan();
|
||||
this->parent_->set_scan_continuous(
|
||||
true); // Set this to true to automatically start scanning again when it has cleaned up.
|
||||
api::BluetoothDeviceClearCacheResponse call;
|
||||
call.address = address;
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::bluetooth_proxy
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
@@ -8,11 +10,14 @@
|
||||
|
||||
#include "esphome/components/api/api_connection.h"
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
@@ -20,14 +25,31 @@
|
||||
#include <esp_bt.h>
|
||||
#endif
|
||||
#include <esp_bt_device.h>
|
||||
#else
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#endif // USE_ESP32
|
||||
|
||||
namespace esphome::bluetooth_proxy {
|
||||
|
||||
static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1;
|
||||
// Proxy-owned error type for the API error fields, which are plain integers on
|
||||
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
|
||||
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
|
||||
// the header independent of how a hub platform's SDK spells its error type.
|
||||
#ifdef USE_ESP32
|
||||
using proxy_err_t = esp_err_t;
|
||||
static constexpr proxy_err_t PROXY_OK = ESP_OK;
|
||||
#else
|
||||
using proxy_err_t = int;
|
||||
static constexpr proxy_err_t PROXY_OK = 0;
|
||||
#endif
|
||||
|
||||
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
static constexpr int DONE_SENDING_SERVICES = -2;
|
||||
static constexpr int INIT_SENDING_SERVICES = -3;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
using namespace esp32_ble_client;
|
||||
#endif
|
||||
|
||||
// Legacy versions:
|
||||
// Version 1: Initial version without active connections
|
||||
@@ -53,21 +75,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
|
||||
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
|
||||
};
|
||||
|
||||
#ifdef USE_ESP32
|
||||
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
public esp32_ble_tracker::BLEScannerStateListener,
|
||||
public Component {
|
||||
friend class BluetoothConnection; // Allow connection to update connections_free_response_
|
||||
#else
|
||||
class BluetoothProxy final : public Component {
|
||||
#endif
|
||||
public:
|
||||
BluetoothProxy();
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
#endif
|
||||
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
#endif // USE_ESP32
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
|
||||
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
@@ -78,6 +107,14 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
|
||||
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
|
||||
// snapshots scan_active()/scan_running() and installs the raw callback, and
|
||||
// the BLEHub contract does not promise those are settled any earlier than
|
||||
// the hub's own setup().
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
|
||||
#endif // USE_ESP32
|
||||
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
|
||||
@@ -92,17 +129,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
void unsubscribe_api_connection(api::APIConnection *api_connection);
|
||||
api::APIConnection *get_api_connection() { return this->api_connection_; }
|
||||
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK);
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK);
|
||||
void send_connections_free();
|
||||
void send_connections_free(api::APIConnection *api_connection);
|
||||
void send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK);
|
||||
void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
|
||||
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
|
||||
|
||||
void bluetooth_scanner_set_mode(bool active);
|
||||
|
||||
#ifdef USE_ESP32
|
||||
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
|
||||
bd_addr[0] = (address >> 40) & 0xff;
|
||||
bd_addr[1] = (address >> 32) & 0xff;
|
||||
@@ -111,12 +149,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
bd_addr[4] = (address >> 8) & 0xff;
|
||||
bd_addr[5] = (address >> 0) & 0xff;
|
||||
}
|
||||
#endif
|
||||
|
||||
void set_active(bool active) { this->active_ = active; }
|
||||
bool has_active() { return this->active_; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// BLEScannerStateListener interface
|
||||
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
|
||||
#endif
|
||||
|
||||
uint32_t get_legacy_version() const {
|
||||
if (this->active_) {
|
||||
@@ -129,7 +170,17 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
uint32_t flags = 0;
|
||||
flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN;
|
||||
flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS;
|
||||
#ifdef USE_ESP32
|
||||
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
|
||||
#else
|
||||
// Advertise mode switching only where the hub honors request_scan_mode();
|
||||
// scan_mode_switch is the capability bit for exactly that (#18079) —
|
||||
// active_scan alone is not enough, a hub may support active scanning yet
|
||||
// refuse the runtime switch.
|
||||
if (this->hub_->get_capabilities().scan_mode_switch) {
|
||||
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
|
||||
}
|
||||
#endif
|
||||
if (this->active_) {
|
||||
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
|
||||
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
|
||||
@@ -142,16 +193,37 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
|
||||
#ifdef USE_ESP32
|
||||
const uint8_t *mac = esp_bt_dev_get_address();
|
||||
if (mac != nullptr) {
|
||||
format_mac_addr_upper(mac, output.data());
|
||||
} else {
|
||||
output[0] = '\0';
|
||||
}
|
||||
#else
|
||||
uint8_t mac[6] = {};
|
||||
this->hub_->get_adapter_mac(mac);
|
||||
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
|
||||
// (rp2040's BTstack) only learn the address once the link layer is up, and
|
||||
// report all-zero until then.
|
||||
bool nonzero = false;
|
||||
for (uint8_t b : mac)
|
||||
nonzero |= b != 0;
|
||||
if (nonzero) {
|
||||
format_mac_addr_upper(mac, output.data());
|
||||
} else {
|
||||
output[0] = '\0';
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
protected:
|
||||
#ifdef USE_ESP32
|
||||
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
|
||||
#else
|
||||
void send_bluetooth_scanner_state_();
|
||||
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
|
||||
#endif
|
||||
|
||||
/// Caller must ensure api_connection_ is non-null and API server is connected.
|
||||
void flush_pending_advertisements_() {
|
||||
@@ -165,9 +237,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
void log_advertisement_flush_();
|
||||
|
||||
#ifdef USE_ESP32
|
||||
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
|
||||
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state);
|
||||
void log_connection_info_(BluetoothConnection *connection, const char *message);
|
||||
#endif
|
||||
void log_not_connected_gatt_(const char *action, const char *type);
|
||||
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
|
||||
|
||||
@@ -175,8 +249,12 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
// Group 1: Pointers (4 bytes each, naturally aligned)
|
||||
api::APIConnection *api_connection_{nullptr};
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// Group 2: Fixed-size array of connection pointers
|
||||
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
|
||||
#else
|
||||
ble_device_base::BLEHub *hub_{nullptr};
|
||||
#endif
|
||||
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
@@ -191,11 +269,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
bool active_;
|
||||
uint8_t connection_count_{0};
|
||||
bool configured_scan_active_{false}; // Configured scan mode from YAML
|
||||
// 3 bytes used, 1 byte padding
|
||||
#ifndef USE_ESP32
|
||||
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
|
||||
#endif
|
||||
};
|
||||
|
||||
extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::bluetooth_proxy
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
|
||||
@@ -25,6 +25,9 @@ static constexpr size_t BTHOME_NONCE_SIZE = 13;
|
||||
static constexpr size_t BTHOME_MIC_SIZE = 4;
|
||||
static constexpr size_t BTHOME_COUNTER_SIZE = 4;
|
||||
|
||||
// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they
|
||||
// compile away and an ungated helper trips -Wunused-function.
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
|
||||
static const char *format_mac_address(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer, uint64_t address) {
|
||||
std::array<uint8_t, MAC_ADDRESS_SIZE> mac{};
|
||||
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
|
||||
@@ -34,6 +37,7 @@ static const char *format_mac_address(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_
|
||||
format_mac_addr_upper(mac.data(), buffer.data());
|
||||
return buffer.data();
|
||||
}
|
||||
#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
|
||||
|
||||
static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) {
|
||||
switch (obj_type) {
|
||||
|
||||
@@ -27,6 +27,7 @@ CONF_LOOP = "loop"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
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_PARITY = "parity"
|
||||
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
|
||||
@@ -36,6 +37,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
|
||||
CONF_STOP_BITS = "stop_bits"
|
||||
CONF_TARGET_COUNT = "target_count"
|
||||
CONF_USE_PSRAM = "use_psram"
|
||||
CONF_VOC_INDEX = "voc_index"
|
||||
CONF_VOLUME_INCREMENT = "volume_increment"
|
||||
|
||||
@@ -311,9 +311,12 @@ bool HOT EPaperT133A01::transfer_data() {
|
||||
this->current_data_index_ = half;
|
||||
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (half < total_rows) {
|
||||
return false;
|
||||
}
|
||||
ESP_LOGD(TAG, "CS phase done");
|
||||
this->disable();
|
||||
this->cs_pin_->digital_write(true); // deselect CS
|
||||
@@ -346,9 +349,12 @@ bool HOT EPaperT133A01::transfer_data() {
|
||||
this->current_data_index_ = half;
|
||||
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (half < total_rows * 2) {
|
||||
return false;
|
||||
}
|
||||
ESP_LOGD(TAG, "CS1 phase done");
|
||||
this->disable();
|
||||
this->cs1_pin_->digital_write(true); // deselect CS1
|
||||
|
||||
@@ -119,6 +119,7 @@ CONF_SIGNING_SCHEME = "signing_scheme"
|
||||
CONF_SRAM1_AS_IRAM = "sram1_as_iram"
|
||||
CONF_SUBTYPE = "subtype"
|
||||
CONF_VERIFICATION_KEY = "verification_key"
|
||||
CONF_VERIFICATION_KEYS = "verification_keys"
|
||||
|
||||
ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
|
||||
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
|
||||
@@ -141,12 +142,22 @@ ASSERTION_LEVELS = {
|
||||
"SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT",
|
||||
}
|
||||
|
||||
SIGNING_SCHEME_RSA3072 = "rsa3072"
|
||||
SIGNING_SCHEME_ECDSA256 = "ecdsa256"
|
||||
SIGNING_SCHEME_ECDSA_V1 = "ecdsa_v1"
|
||||
|
||||
SIGNING_SCHEMES = {
|
||||
"rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
|
||||
"ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
|
||||
"ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
|
||||
SIGNING_SCHEME_RSA3072: "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
|
||||
SIGNING_SCHEME_ECDSA256: "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
|
||||
SIGNING_SCHEME_ECDSA_V1: "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
|
||||
}
|
||||
|
||||
# A Secure Boot v2 image carries at most three signature blocks, and hardware
|
||||
# secure boot exposes three eFuse key slots. The trusted-key list isn't bound by
|
||||
# the per-image limit (an incoming image need only match one trusted key), but
|
||||
# cap it at three to mirror those hardware limits.
|
||||
SIGNED_OTA_MAX_KEYS = 3
|
||||
|
||||
# Chip variants that only support one V2 signing scheme.
|
||||
# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h.
|
||||
# Variants not listed in either set support both RSA and ECDSA V2
|
||||
@@ -628,7 +639,6 @@ class NetworkSdkconfigData:
|
||||
wifi_ap: bool = False # WiFi AP mode configured
|
||||
ethernet: bool = False # Ethernet component active
|
||||
bluetooth: bool = False # any BLE component active
|
||||
ble_42: bool = False # BLE 4.2 features needed
|
||||
software_coexistence: bool = False # WiFi/BT software coexistence requested
|
||||
# esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset)
|
||||
enable_lwip_dhcp_server: bool | None = None
|
||||
@@ -654,12 +664,10 @@ def request_ethernet() -> None:
|
||||
_network_sdkconfig().ethernet = True
|
||||
|
||||
|
||||
def request_bluetooth(ble_42: bool = False) -> None:
|
||||
"""Request the Bluetooth controller. Pass ble_42=True for 4.2 features."""
|
||||
def request_bluetooth() -> None:
|
||||
"""Request the Bluetooth controller."""
|
||||
net = _network_sdkconfig()
|
||||
net.bluetooth = True
|
||||
if ble_42:
|
||||
net.ble_42 = True
|
||||
|
||||
|
||||
def request_software_coexistence() -> None:
|
||||
@@ -1167,11 +1175,99 @@ def _ota_downgrade_protection_errors(
|
||||
return errs
|
||||
|
||||
|
||||
def _sbv2_rsa_key_digest(path: Path) -> bytes:
|
||||
"""SHA-256 of a public key's Secure Boot v2 signature-block key region.
|
||||
|
||||
This hashes the 776-byte {n, e, rinv, m'} region exactly as the ROM lays it
|
||||
out -- i.e. the value the device computes per signature block and the one
|
||||
``espsecure digest-sbv2-public-key`` prints, not a hash of the DER key.
|
||||
"""
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
from cryptography.exceptions import UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
if b"PUBLIC KEY" in data:
|
||||
public_key = load_pem_public_key(data)
|
||||
else:
|
||||
# verification_keys only needs the public half; warn so the private
|
||||
# key doesn't end up committed alongside the config.
|
||||
_LOGGER.warning(
|
||||
"'%s' is a private key, but '%s' needs only the public key. Use a "
|
||||
"public-key PEM or the 64-hex digest (espsecure "
|
||||
"digest-sbv2-public-key) so the private key stays out of your config.",
|
||||
path,
|
||||
CONF_VERIFICATION_KEYS,
|
||||
)
|
||||
public_key = load_pem_private_key(data, password=None).public_key()
|
||||
except (ValueError, TypeError, UnsupportedAlgorithm) as err:
|
||||
raise cv.Invalid(f"Could not load key '{path}': {err}") from err
|
||||
if not isinstance(public_key, rsa.RSAPublicKey) or public_key.key_size != 3072:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_VERIFICATION_KEYS}' entries must be RSA-3072 keys; "
|
||||
f"'{path}' is not."
|
||||
)
|
||||
numbers = public_key.public_numbers()
|
||||
n, e = numbers.n, numbers.e
|
||||
m = (-pow(n, -1, 1 << 32)) & 0xFFFFFFFF
|
||||
rinv = (1 << (public_key.key_size * 2)) % n
|
||||
blob = struct.pack(
|
||||
"<384sI384sI",
|
||||
n.to_bytes(384, "big")[::-1],
|
||||
e,
|
||||
rinv.to_bytes(384, "big")[::-1],
|
||||
m,
|
||||
)
|
||||
return hashlib.sha256(blob).digest()
|
||||
|
||||
|
||||
def _validate_trusted_key(value: Any) -> str:
|
||||
"""Normalize a trusted key to its 64-hex-char signature-block digest.
|
||||
|
||||
Accepts either the digest directly (so CI can inject it without shipping a
|
||||
key file) or a PEM key file whose digest is computed here. Typed ``Any``
|
||||
because YAML hands validators the parsed value -- e.g. an unquoted ``0x...``
|
||||
digest arrives as an int, which the guard below rejects with advice to quote.
|
||||
"""
|
||||
# An unquoted 0x... or all-digit digest is parsed by YAML as an int before it
|
||||
# reaches here, so it never looks like a string digest -- reject it clearly
|
||||
# rather than letting it fall through to cv.file_ as a bogus path.
|
||||
if not isinstance(value, str):
|
||||
raise cv.Invalid(
|
||||
f"Expected a key file path or a 64-character hex digest, got {value!r}. "
|
||||
f"Quote the digest so YAML keeps it as text (an unquoted '0x...' or "
|
||||
f"all-digit value is parsed as a number)."
|
||||
)
|
||||
stripped = value.strip()
|
||||
if re.fullmatch(r"[0-9A-Fa-f]{64}", stripped):
|
||||
return stripped.lower()
|
||||
# An all-hex value that isn't exactly 64 chars is a mangled digest, not a
|
||||
# path: a truncated or 0x-prefixed CI variable would otherwise fall through
|
||||
# and fail as "file not found", pointing at the wrong problem.
|
||||
if re.fullmatch(r"(?:0x)?[0-9A-Fa-f]+", stripped):
|
||||
raise cv.Invalid(
|
||||
f"'{stripped}' looks like a key digest but must be exactly 64 hex "
|
||||
f"characters (a SHA-256, no '0x' prefix); check for truncation."
|
||||
)
|
||||
return _sbv2_rsa_key_digest(cv.file_(value)).hex()
|
||||
|
||||
|
||||
_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SIGNING_KEY): cv.file_,
|
||||
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
|
||||
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
|
||||
cv.Optional(CONF_VERIFICATION_KEYS): cv.All(
|
||||
cv.ensure_list(_validate_trusted_key),
|
||||
cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS),
|
||||
),
|
||||
cv.Optional(CONF_SIGNING_SCHEME, default=SIGNING_SCHEME_RSA3072): cv.one_of(
|
||||
*SIGNING_SCHEMES, lower=True
|
||||
),
|
||||
}
|
||||
@@ -1204,9 +1300,15 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
|
||||
block appended to each image, so verifying externally-signed binaries
|
||||
needs no key in the config at all -- omitting both keys selects that
|
||||
external-signing mode.
|
||||
|
||||
For external RSA (rsa3072, no signing key), an optional 'verification_keys'
|
||||
list names the keys the running app trusts. ESPHome then verifies OTA
|
||||
signatures against that compiled-in set instead of IDF's single-block
|
||||
check, which enables key rotation and multi-provider backup keys.
|
||||
"""
|
||||
has_signing_key = CONF_SIGNING_KEY in config
|
||||
has_verification_key = CONF_VERIFICATION_KEY in config
|
||||
has_verification_keys = CONF_VERIFICATION_KEYS in config
|
||||
scheme = config[CONF_SIGNING_SCHEME]
|
||||
if has_signing_key and has_verification_key:
|
||||
raise cv.Invalid(
|
||||
@@ -1214,7 +1316,35 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
|
||||
f"'{CONF_VERIFICATION_KEY}', not both.",
|
||||
path=[CONF_VERIFICATION_KEY],
|
||||
)
|
||||
if scheme == "ecdsa_v1":
|
||||
if has_verification_keys:
|
||||
if scheme != SIGNING_SCHEME_RSA3072:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme "
|
||||
f"'rsa3072' (externally-signed RSA images). With '{scheme}' the "
|
||||
f"public key travels in each image's signature block.",
|
||||
path=[CONF_VERIFICATION_KEYS],
|
||||
)
|
||||
if has_signing_key:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_VERIFICATION_KEYS}' verifies externally-signed images "
|
||||
f"and cannot be combined with '{CONF_SIGNING_KEY}' (which signs "
|
||||
f"during the build). Provide one or the other.",
|
||||
path=[CONF_VERIFICATION_KEYS],
|
||||
)
|
||||
if has_verification_key:
|
||||
raise cv.Invalid(
|
||||
f"Provide at most one of '{CONF_VERIFICATION_KEY}' and "
|
||||
f"'{CONF_VERIFICATION_KEYS}', not both.",
|
||||
path=[CONF_VERIFICATION_KEYS],
|
||||
)
|
||||
keys = config[CONF_VERIFICATION_KEYS]
|
||||
if len(set(keys)) != len(keys):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_VERIFICATION_KEYS}' entries must be unique (duplicate "
|
||||
f"keys add nothing and waste a trusted-set slot).",
|
||||
path=[CONF_VERIFICATION_KEYS],
|
||||
)
|
||||
if scheme == SIGNING_SCHEME_ECDSA_V1:
|
||||
if not has_signing_key and not has_verification_key:
|
||||
raise cv.Invalid(
|
||||
f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' "
|
||||
@@ -1370,7 +1500,10 @@ def final_validate(config):
|
||||
]
|
||||
|
||||
# V1 ECDSA is only available on the original ESP32
|
||||
if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS:
|
||||
if (
|
||||
scheme == SIGNING_SCHEME_ECDSA_V1
|
||||
and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS
|
||||
):
|
||||
errs.append(
|
||||
cv.Invalid(
|
||||
f"Signing scheme 'ecdsa_v1' is only supported on "
|
||||
@@ -1383,7 +1516,9 @@ def final_validate(config):
|
||||
# On ESP32, V2 RSA requires minimum_chip_revision >= 3.0
|
||||
# Note: string comparison works here because cv.one_of constrains
|
||||
# min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1").
|
||||
if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"):
|
||||
if scheme == SIGNING_SCHEME_RSA3072 and (
|
||||
min_rev is None or min_rev < "3.0"
|
||||
):
|
||||
errs.append(
|
||||
cv.Invalid(
|
||||
f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} "
|
||||
@@ -1394,7 +1529,7 @@ def final_validate(config):
|
||||
)
|
||||
)
|
||||
# ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC)
|
||||
elif scheme == "ecdsa256":
|
||||
elif scheme == SIGNING_SCHEME_ECDSA256:
|
||||
errs.append(
|
||||
cv.Invalid(
|
||||
f"Signing scheme 'ecdsa256' is not supported on "
|
||||
@@ -1404,7 +1539,11 @@ def final_validate(config):
|
||||
)
|
||||
)
|
||||
# V1 on rev 3.0+ -- suggest V2 RSA for stronger security
|
||||
elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0":
|
||||
elif (
|
||||
scheme == SIGNING_SCHEME_ECDSA_V1
|
||||
and min_rev is not None
|
||||
and min_rev >= "3.0"
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Using Secure Boot V1 ECDSA on %s rev %s. "
|
||||
"Consider using 'rsa3072' (Secure Boot V2 RSA) for "
|
||||
@@ -1415,8 +1554,14 @@ def final_validate(config):
|
||||
else:
|
||||
# Non-ESP32 variants: check V2 scheme-variant compatibility
|
||||
scheme_variant_conflicts = {
|
||||
"ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"),
|
||||
"rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"),
|
||||
SIGNING_SCHEME_ECDSA256: (
|
||||
SIGNED_OTA_V2_RSA_ONLY_VARIANTS,
|
||||
SIGNING_SCHEME_RSA3072,
|
||||
),
|
||||
SIGNING_SCHEME_RSA3072: (
|
||||
SIGNED_OTA_V2_ECC_ONLY_VARIANTS,
|
||||
SIGNING_SCHEME_ECDSA256,
|
||||
),
|
||||
}
|
||||
if (
|
||||
conflict := scheme_variant_conflicts.get(scheme)
|
||||
@@ -2055,12 +2200,12 @@ async def _reconcile_network_sdkconfig() -> None:
|
||||
if name not in opts:
|
||||
add_idf_sdkconfig_option(name, value)
|
||||
|
||||
# Bluetooth: only ever enable when requested. The IDF default is off and
|
||||
# nothing sets these False today, so never write False here.
|
||||
# Bluetooth: only ever enable when requested. The IDF default is off.
|
||||
# According to the IDF docs, only one of 4.2 or 5.0 should be enabled.
|
||||
if net.bluetooth:
|
||||
set_opt("CONFIG_BT_ENABLED", True)
|
||||
if net.ble_42:
|
||||
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
|
||||
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
|
||||
set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
|
||||
|
||||
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
|
||||
# relies on the IDF default (enabled), so it is never written True here.
|
||||
@@ -2193,6 +2338,8 @@ async def to_code(config):
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
# NVS finds stored preferences by key, so preference key migration is possible
|
||||
cg.add_define("USE_PREFERENCE_KEY_LOOKUP")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
|
||||
CORE.add_job(_finalize_arduino_aware_flags)
|
||||
@@ -2557,9 +2704,70 @@ async def to_code(config):
|
||||
# Enable signed app verification without hardware secure boot
|
||||
if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION):
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True)
|
||||
|
||||
scheme = signed_ota[CONF_SIGNING_SCHEME]
|
||||
# For externally-signed RSA images with a declared 'verification_keys'
|
||||
# list, ESPHome verifies the OTA signature itself instead of using IDF's
|
||||
# on-update check. IDF only matches the incoming image's first signature
|
||||
# block against the running app's first, which blocks key rotation and
|
||||
# multi-provider backup keys; ESPHome accepts an image signed by any key
|
||||
# in the compiled-in trusted set. Without 'verification_keys' there is no
|
||||
# trust anchor, so fall back to IDF's built-in check.
|
||||
# The build still produces the padded unsigned image (via SECURE_
|
||||
# SIGNED_APPS_NO_SECURE_BOOT above); only the on-update check moves.
|
||||
# SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT defaults to y under
|
||||
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it must be set explicitly:
|
||||
# False to hand verification to ESPHome, True to keep IDF's check.
|
||||
# Setting it False also drives the hidden CONFIG_SECURE_SIGNED_APPS to
|
||||
# n; the 4 KiB padding and reserved signature sector the verifier
|
||||
# depends on survive only because --secure-pad-v2 keys off
|
||||
# CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol.
|
||||
external_rsa = (
|
||||
scheme == SIGNING_SCHEME_RSA3072 and CONF_SIGNING_KEY not in signed_ota
|
||||
)
|
||||
verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS)
|
||||
# verification_keys is accepted only for external RSA (rsa3072 with no
|
||||
# signing_key), enforced in _validate_signed_ota_keys. Assert the
|
||||
# post-condition so validator/codegen drift fails the build loudly
|
||||
# instead of silently dropping the declared trust anchor and downgrading
|
||||
# to IDF's single-block check.
|
||||
assert not verification_keys or external_rsa
|
||||
multi_key = external_rsa and verification_keys
|
||||
# Turning IDF's on-update check off is global -- it also drops the
|
||||
# signature check from esp_ota_set_boot_partition() on the partition-table
|
||||
# path and safe_mode's recovery rollback. Both deliberately select an
|
||||
# already-installed image (or an MD5-checked partition table), not a
|
||||
# freshly-downloaded one, so ESPHome's verifier only needs to cover the
|
||||
# app and bootloader OTA paths, where a new image is actually written.
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", not multi_key
|
||||
)
|
||||
if multi_key:
|
||||
cg.add_define("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY")
|
||||
# Compile the trusted key digests in as the immutable trust anchor.
|
||||
# Each is the SHA-256 of a key's signature-block region; the verifier
|
||||
# accepts an OTA whose signature block matches one of these.
|
||||
digests = [bytes.fromhex(k) for k in verification_keys]
|
||||
# Echo the resolved digests so a stale or mistyped key (which builds
|
||||
# cleanly but leaves the device updatable only by serial reflash) is
|
||||
# visible in the build log.
|
||||
_LOGGER.info(
|
||||
"Signed OTA verification trusts %d key digest(s): %s",
|
||||
len(digests),
|
||||
", ".join(d.hex() for d in digests),
|
||||
)
|
||||
cg.add_define("OTA_TRUSTED_KEY_COUNT", len(digests))
|
||||
cg.add_define(
|
||||
"OTA_TRUSTED_KEY_DIGESTS",
|
||||
cg.RawExpression(
|
||||
"{"
|
||||
+ ",".join(
|
||||
"{" + ",".join(f"0x{b:02x}" for b in d) + "}" for d in digests
|
||||
)
|
||||
+ "}"
|
||||
),
|
||||
)
|
||||
|
||||
for key, flag in SIGNING_SCHEMES.items():
|
||||
add_idf_sdkconfig_option(flag, scheme == key)
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
KEY_ESP32 = "esp32"
|
||||
# Re-exported for the many esp32-side users; defined in esphome.const
|
||||
# and esphome.espidf so the upload/logs fast path can use them without
|
||||
# importing this package.
|
||||
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
|
||||
KEY_ESP32,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
|
||||
# Back compat for external components only; in-tree callers import it
|
||||
# from esphome.espidf directly.
|
||||
from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
|
||||
variant_to_idf_target,
|
||||
)
|
||||
|
||||
KEY_BOARD = "board"
|
||||
KEY_FLASH_SIZE = "flash_size"
|
||||
KEY_VARIANT = "variant"
|
||||
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
|
||||
KEY_COMPONENTS = "components"
|
||||
KEY_EXCLUDE_COMPONENTS = "exclude_components"
|
||||
@@ -15,7 +28,6 @@ KEY_PATH = "path"
|
||||
KEY_SUBMODULES = "submodules"
|
||||
KEY_EXTRA_BUILD_FILES = "extra_build_files"
|
||||
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
|
||||
KEY_IDF_VERSION = "idf_version"
|
||||
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
|
||||
|
||||
VARIANT_ESP32 = "ESP32"
|
||||
@@ -64,9 +76,4 @@ VARIANT_FRIENDLY = {
|
||||
}
|
||||
|
||||
|
||||
def variant_to_idf_target(variant: str) -> str:
|
||||
"""Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name."""
|
||||
return variant.lower().replace("-", "")
|
||||
|
||||
|
||||
esp32_ns = cg.esphome_ns.namespace("esp32")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
|
||||
#include "crash_handler.h"
|
||||
#include "esphome/core/build_info_data.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
@@ -122,7 +123,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
|
||||
// Magic is second to validate the data. Remaining fields can change between versions.
|
||||
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
|
||||
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
|
||||
static constexpr uint32_t CRASH_DATA_VERSION = 3;
|
||||
static constexpr uint32_t CRASH_DATA_VERSION = 4;
|
||||
struct RawCrashData {
|
||||
uint32_t version;
|
||||
uint32_t magic;
|
||||
@@ -134,6 +135,7 @@ struct RawCrashData {
|
||||
uint32_t backtrace[MAX_BACKTRACE];
|
||||
uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V)
|
||||
uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V)
|
||||
uint32_t build_time; // ESPHOME_BUILD_TIME of the firmware that captured this record
|
||||
uint8_t crashed_core;
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores");
|
||||
@@ -152,6 +154,16 @@ namespace esphome::esp32 {
|
||||
|
||||
static const char *const 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
|
||||
// cache-error panics), so the wrapper stamps the record from this mirror
|
||||
// instead. Filled during C++ dynamic initialization, well before arch_init();
|
||||
// ESPHOME_BUILD_TIME itself is constant-initialized, so the read is ordered.
|
||||
// Unqualified name on purpose: the runtime header declares it in namespace
|
||||
// esphome, while the static-analysis stub defines it as a macro.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME);
|
||||
|
||||
void crash_handler_read_and_clear() {
|
||||
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
|
||||
s_crash_data_valid = true;
|
||||
@@ -331,6 +343,66 @@ static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *ad
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Register holding the faulting memory address, named as in ESP-IDF's live
|
||||
// register dump. The lowercase form is for old-build reports, where the
|
||||
// stacktrace decoders must not match the line.
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
static const char *const FAULT_ADDR_REG = "EXCVADDR";
|
||||
static const char *const FAULT_ADDR_REG_LOWER = "excvaddr";
|
||||
#elif CONFIG_IDF_TARGET_ARCH_RISCV
|
||||
static const char *const FAULT_ADDR_REG = "MTVAL";
|
||||
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
|
||||
#endif
|
||||
|
||||
// Whether the fault address is meaningful — real CPU faults only, not
|
||||
// aborts/watchdogs or SoC-level pseudo exceptions.
|
||||
static bool has_fault_addr() {
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
|
||||
}
|
||||
|
||||
// Append both cores' backtrace addresses to buf; returns the new position.
|
||||
static int append_all_backtraces(char *buf, int size, int pos) {
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
|
||||
s_raw_crash_data.other_reg_frame_count);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
|
||||
// The record was captured by a different firmware build (it survives soft
|
||||
// resets, including the OTA reboot), so symbolizing its addresses against the
|
||||
// current ELF would produce misleading symbols. Print them with lowercase
|
||||
// labels the stacktrace decoders deliberately do not match, and skip the
|
||||
// addr2line hint. One line per address so nothing is lost to a shared buffer.
|
||||
// No is_return_addr() filtering here: it would inspect the current build's
|
||||
// code bytes, which say nothing about addresses captured by the old build.
|
||||
static uint8_t log_foreign_backtrace(const uint32_t *addrs, uint8_t count, uint8_t bt_num) {
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
ESP_LOGE(TAG, " bt%d: 0x%08" PRIX32, bt_num++, addrs[i]);
|
||||
}
|
||||
return bt_num;
|
||||
}
|
||||
|
||||
static void log_foreign_addresses() {
|
||||
ESP_LOGE(TAG, " Captured by a different firmware build; addresses belong to that build's ELF");
|
||||
ESP_LOGE(TAG, " pc: 0x%08" PRIX32, s_raw_crash_data.pc);
|
||||
if (has_fault_addr()) {
|
||||
ESP_LOGE(TAG, " %s: 0x%08" PRIX32, FAULT_ADDR_REG_LOWER, s_raw_crash_data.fault_addr);
|
||||
}
|
||||
uint8_t bt_num = log_foreign_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, 0);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
if (s_raw_crash_data.other_backtrace_count > 0) {
|
||||
// Lowercase like the address labels: carries no address, matches no decoder.
|
||||
ESP_LOGE(TAG, " other core (%d):", 1 - s_raw_crash_data.crashed_core);
|
||||
log_foreign_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, bt_num);
|
||||
}
|
||||
#else
|
||||
(void) bt_num; // Single-core targets have no second list to continue numbering into.
|
||||
#endif
|
||||
}
|
||||
|
||||
// Intentionally uses separate ESP_LOGE calls per line instead of combining into
|
||||
// one multi-line log message. This ensures each address appears as its own line
|
||||
// on the serial console, making it possible to see partial output if the device
|
||||
@@ -348,18 +420,17 @@ void crash_handler_log() {
|
||||
ESP_LOGE(TAG, " Reason: %s", get_exception_type());
|
||||
}
|
||||
ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core);
|
||||
if (s_raw_crash_data.build_time != s_current_build_time) {
|
||||
// Captured by a different firmware build: the record survives soft resets
|
||||
// including the OTA reboot, so its addresses belong to a previous ELF.
|
||||
log_foreign_addresses();
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc);
|
||||
// Faulting memory address — only meaningful for real CPU faults, not
|
||||
// aborts/watchdogs or SoC-level pseudo exceptions. Uses the same register
|
||||
// name as ESP-IDF's live register dump for the architecture (EXCVADDR on
|
||||
// Xtensa, MTVAL on RISC-V) so the CLI decodes it when it happens to be a
|
||||
// code address.
|
||||
if (s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause) {
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr);
|
||||
#elif CONFIG_IDF_TARGET_ARCH_RISCV
|
||||
ESP_LOGE(TAG, " MTVAL: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr);
|
||||
#endif
|
||||
// Uses the same register name as ESP-IDF's live register dump so the CLI
|
||||
// decodes the address when it happens to be a code address.
|
||||
if (has_fault_addr()) {
|
||||
ESP_LOGE(TAG, " %s: 0x%08" PRIX32 " (faulting address)", FAULT_ADDR_REG, s_raw_crash_data.fault_addr);
|
||||
}
|
||||
log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count);
|
||||
|
||||
@@ -375,14 +446,7 @@ void crash_handler_log() {
|
||||
// Build addr2line hint with all captured addresses for easy copy-paste
|
||||
char hint[256];
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
|
||||
pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
|
||||
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
|
||||
#else
|
||||
(void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread.
|
||||
#endif
|
||||
append_all_backtraces(hint, sizeof(hint), pos);
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
}
|
||||
|
||||
@@ -408,6 +472,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
|
||||
s_raw_crash_data.cause = 0;
|
||||
s_raw_crash_data.fault_addr = 0;
|
||||
// Record which build's ELF the captured addresses belong to (RAM read, panic-safe).
|
||||
// Still 0 if the panic precedes C++ dynamic initialization, so such a crash
|
||||
// reports as a foreign build — conservative: addresses are shown raw instead
|
||||
// of decoded.
|
||||
s_raw_crash_data.build_time = esphome::esp32::s_current_build_time;
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
s_raw_crash_data.other_backtrace_count = 0;
|
||||
s_raw_crash_data.other_reg_frame_count = 0;
|
||||
|
||||
@@ -176,12 +176,21 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty
|
||||
}
|
||||
s_open_err = ESP_OK;
|
||||
}
|
||||
auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
pref->nvs_handle = this->nvs_handle;
|
||||
pref->key = type;
|
||||
pref->in_flash = true;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
return ESPPreferenceObject(new ESP32PreferenceBackend(this->make_backend_(type)));
|
||||
}
|
||||
|
||||
return ESPPreferenceObject(pref);
|
||||
ESP32PreferenceBackend ESP32Preferences::make_backend_(uint32_t type) const {
|
||||
// in_flash keeps its default of true, selecting the NVS path
|
||||
ESP32PreferenceBackend backend;
|
||||
backend.nvs_handle = this->nvs_handle;
|
||||
backend.key = type;
|
||||
return backend;
|
||||
}
|
||||
|
||||
bool ESP32Preferences::load_from_key(uint32_t type, uint8_t *data, size_t len) {
|
||||
ESP32PreferenceBackend backend = this->make_backend_(type);
|
||||
return backend.load(data, len);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE
|
||||
|
||||
@@ -23,12 +23,15 @@ class ESP32Preferences final : public PreferencesMixin<ESP32Preferences> {
|
||||
ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash);
|
||||
// Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior.
|
||||
ESPPreferenceObject make_preference(size_t length, uint32_t type);
|
||||
/// One-shot read of a stored preference by key, without allocating a backend
|
||||
bool load_from_key(uint32_t type, uint8_t *data, size_t len);
|
||||
bool sync();
|
||||
bool reset();
|
||||
|
||||
uint32_t nvs_handle;
|
||||
|
||||
protected:
|
||||
ESP32PreferenceBackend make_backend_(uint32_t type) const;
|
||||
bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str);
|
||||
|
||||
#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Callable, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -32,7 +31,7 @@ from esphome.const import (
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
|
||||
from esphome.core import CORE, TimePeriod
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -134,18 +133,21 @@ def _get_required_loggers() -> set[BTLoggers]:
|
||||
return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set())
|
||||
|
||||
|
||||
# Dataclass for handler registration counts
|
||||
@dataclass
|
||||
class HandlerCounts:
|
||||
gap_event: int = 0
|
||||
gap_scan_event: int = 0
|
||||
gattc_event: int = 0
|
||||
gatts_event: int = 0
|
||||
ble_status_event: int = 0
|
||||
|
||||
|
||||
# Track handler registration counts for StaticVector sizing
|
||||
_handler_counts = HandlerCounts()
|
||||
# Handler slot counters sizing the StaticCallbackManager storage in ble.h;
|
||||
# one request per register_* call below.
|
||||
_request_gap_event_slot = cg.slot_counter("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT")
|
||||
_request_gap_scan_event_slot = cg.slot_counter(
|
||||
"ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT"
|
||||
)
|
||||
_request_gattc_event_slot = cg.slot_counter(
|
||||
"ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT"
|
||||
)
|
||||
_request_gatts_event_slot = cg.slot_counter(
|
||||
"ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT"
|
||||
)
|
||||
_request_ble_status_event_slot = cg.slot_counter(
|
||||
"ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT"
|
||||
)
|
||||
|
||||
|
||||
def _add_callback(
|
||||
@@ -171,8 +173,8 @@ def _add_callback(
|
||||
|
||||
|
||||
def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None:
|
||||
"""Register a GAP event handler and track the count."""
|
||||
_handler_counts.gap_event += 1
|
||||
"""Register a GAP event handler and request a handler slot."""
|
||||
_request_gap_event_slot()
|
||||
_add_callback(
|
||||
parent_var,
|
||||
"add_gap_event_callback",
|
||||
@@ -185,8 +187,8 @@ def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj)
|
||||
def register_gap_scan_event_handler(
|
||||
parent_var: cg.MockObj, handler_var: cg.MockObj
|
||||
) -> None:
|
||||
"""Register a GAP scan event handler and track the count."""
|
||||
_handler_counts.gap_scan_event += 1
|
||||
"""Register a GAP scan event handler and request a handler slot."""
|
||||
_request_gap_scan_event_slot()
|
||||
_add_callback(
|
||||
parent_var,
|
||||
"add_gap_scan_event_callback",
|
||||
@@ -199,8 +201,8 @@ def register_gap_scan_event_handler(
|
||||
def register_gattc_event_handler(
|
||||
parent_var: cg.MockObj, handler_var: cg.MockObj
|
||||
) -> None:
|
||||
"""Register a GATTc event handler and track the count."""
|
||||
_handler_counts.gattc_event += 1
|
||||
"""Register a GATTc event handler and request a handler slot."""
|
||||
_request_gattc_event_slot()
|
||||
_add_callback(
|
||||
parent_var,
|
||||
"add_gattc_event_callback",
|
||||
@@ -213,8 +215,8 @@ def register_gattc_event_handler(
|
||||
def register_gatts_event_handler(
|
||||
parent_var: cg.MockObj, handler_var: cg.MockObj
|
||||
) -> None:
|
||||
"""Register a GATTs event handler and track the count."""
|
||||
_handler_counts.gatts_event += 1
|
||||
"""Register a GATTs event handler and request a handler slot."""
|
||||
_request_gatts_event_slot()
|
||||
_add_callback(
|
||||
parent_var,
|
||||
"add_gatts_event_callback",
|
||||
@@ -227,8 +229,8 @@ def register_gatts_event_handler(
|
||||
def register_ble_status_event_handler(
|
||||
parent_var: cg.MockObj, handler_var: cg.MockObj
|
||||
) -> None:
|
||||
"""Register a BLE status event handler and track the count."""
|
||||
_handler_counts.ble_status_event += 1
|
||||
"""Register a BLE status event handler and request a handler slot."""
|
||||
_request_ble_status_event_slot()
|
||||
_add_callback(
|
||||
parent_var,
|
||||
"add_ble_status_event_callback",
|
||||
@@ -518,36 +520,6 @@ def final_validation(config):
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
|
||||
# This needs to be run as a job with CoroPriority.FINAL priority so that all components have
|
||||
# a chance to register their handlers before the counts are added to defines.
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_ble_handler_defines():
|
||||
# Add defines for StaticVector sizing based on handler registration counts
|
||||
# Only define if count > 0 to avoid allocating unnecessary memory
|
||||
if _handler_counts.gap_event > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT", _handler_counts.gap_event
|
||||
)
|
||||
if _handler_counts.gap_scan_event > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT",
|
||||
_handler_counts.gap_scan_event,
|
||||
)
|
||||
if _handler_counts.gattc_event > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT", _handler_counts.gattc_event
|
||||
)
|
||||
if _handler_counts.gatts_event > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT", _handler_counts.gatts_event
|
||||
)
|
||||
if _handler_counts.ble_status_event > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT",
|
||||
_handler_counts.ble_status_event,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
|
||||
@@ -576,7 +548,7 @@ async def to_code(config):
|
||||
max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)
|
||||
cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections)
|
||||
|
||||
request_bluetooth(ble_42=True)
|
||||
request_bluetooth()
|
||||
|
||||
# When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for
|
||||
# heap allocations and use dynamic (heap-based) environment memory tables
|
||||
@@ -633,9 +605,6 @@ async def to_code(config):
|
||||
cg.add_define("USE_ESP32_BLE_ADVERTISING")
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
|
||||
# Schedule the handler defines to be added after all components register
|
||||
CORE.add_job(_add_ble_handler_defines)
|
||||
|
||||
|
||||
@automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({}))
|
||||
async def ble_enabled_to_code(config, condition_id, template_arg, args):
|
||||
|
||||
@@ -86,4 +86,4 @@ async def to_code(config):
|
||||
|
||||
cg.add_define("USE_ESP32_BLE_ADVERTISING")
|
||||
|
||||
request_bluetooth(ble_42=True)
|
||||
request_bluetooth()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
request_bluetooth,
|
||||
@@ -44,8 +43,9 @@ AUTO_LOAD = ["ble_device_base", "esp32_ble"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
CODEOWNERS = ["@bdraco"]
|
||||
|
||||
ble_device_base.register_hub_provider("esp32_ble_tracker")
|
||||
|
||||
CONF_ESP32_BLE_ID = "esp32_ble_id"
|
||||
CONF_ON_SCAN_END = "on_scan_end"
|
||||
CONF_SOFTWARE_COEXISTENCE = "software_coexistence"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -56,16 +56,8 @@ class BLEFeatures(StrEnum):
|
||||
ESP_BT_DEVICE = "ESP_BT_DEVICE"
|
||||
|
||||
|
||||
# Dataclass for registration counts
|
||||
@dataclass
|
||||
class RegistrationCounts:
|
||||
listeners: int = 0
|
||||
clients: int = 0
|
||||
|
||||
|
||||
# CORE.data keys for state management
|
||||
# CORE.data key for state management
|
||||
ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features"
|
||||
ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts"
|
||||
|
||||
|
||||
def _get_required_features() -> set[BLEFeatures]:
|
||||
@@ -73,11 +65,13 @@ def _get_required_features() -> set[BLEFeatures]:
|
||||
return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set())
|
||||
|
||||
|
||||
def _get_registration_counts() -> RegistrationCounts:
|
||||
"""Get the registration counts from CORE.data."""
|
||||
return CORE.data.setdefault(
|
||||
ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts()
|
||||
)
|
||||
# Slot counters sizing the tracker's StaticVector storage; one request per
|
||||
# registered listener, client, or scanner state listener.
|
||||
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
|
||||
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
|
||||
_request_scanner_state_listener_slot = cg.slot_counter(
|
||||
"ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT"
|
||||
)
|
||||
|
||||
|
||||
def register_ble_features(features: set[BLEFeatures]) -> None:
|
||||
@@ -239,17 +233,15 @@ async def to_code(config):
|
||||
):
|
||||
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
|
||||
|
||||
registration_counts = _get_registration_counts()
|
||||
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
registration_counts.listeners += 1
|
||||
_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))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []):
|
||||
registration_counts.listeners += 1
|
||||
_request_listener_slot()
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID])))
|
||||
@@ -262,7 +254,7 @@ async def to_code(config):
|
||||
cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex))
|
||||
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
|
||||
for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []):
|
||||
registration_counts.listeners += 1
|
||||
_request_listener_slot()
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format):
|
||||
cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID])))
|
||||
@@ -275,7 +267,7 @@ async def to_code(config):
|
||||
cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex))
|
||||
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
|
||||
for conf in config.get(CONF_ON_SCAN_END, []):
|
||||
registration_counts.listeners += 1
|
||||
_request_listener_slot()
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
@@ -308,28 +300,14 @@ async def _add_ble_features():
|
||||
required_features = _get_required_features()
|
||||
# Sensors registered through the neutral ble_device_base path (BLEHub) need
|
||||
# the parsed-device pipeline compiled in, exactly like esp32-path listeners.
|
||||
neutral_listener_count = ble_device_base.get_listener_count()
|
||||
if neutral_listener_count > 0:
|
||||
if cg.get_slot_count(ble_device_base.LISTENER_COUNT_DEFINE):
|
||||
# The neutral (BLEHub) listener count define itself is emitted by
|
||||
# ble_device_base's own job; only the feature coupling lives here.
|
||||
required_features.add(BLEFeatures.ESP_BT_DEVICE)
|
||||
# StaticVector sizing for the neutral (BLEHub) listener list — same
|
||||
# pattern as the esp32-path registration counts below.
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count)
|
||||
if BLEFeatures.ESP_BT_DEVICE in required_features:
|
||||
cg.add_define("USE_ESP32_BLE_DEVICE")
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
|
||||
# Add defines for StaticVector sizing based on registration counts
|
||||
# Only define if count > 0 to avoid allocating unnecessary memory
|
||||
registration_counts = _get_registration_counts()
|
||||
if registration_counts.listeners > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners
|
||||
)
|
||||
if registration_counts.clients > 0:
|
||||
cg.add_define(
|
||||
"ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients
|
||||
)
|
||||
|
||||
|
||||
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -382,7 +360,7 @@ async def register_ble_device(
|
||||
var: cg.SafeExpType, config: ConfigType
|
||||
) -> cg.SafeExpType:
|
||||
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
|
||||
_get_registration_counts().listeners += 1
|
||||
_request_listener_slot()
|
||||
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
|
||||
cg.add(paren.register_listener(var))
|
||||
return var
|
||||
@@ -390,7 +368,7 @@ async def register_ble_device(
|
||||
|
||||
async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType:
|
||||
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
|
||||
_get_registration_counts().clients += 1
|
||||
_request_client_slot()
|
||||
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
|
||||
cg.add(paren.register_client(var))
|
||||
return var
|
||||
@@ -404,7 +382,7 @@ async def register_raw_ble_device(
|
||||
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
|
||||
will not be compiled in if this is the only registration method used.
|
||||
"""
|
||||
_get_registration_counts().listeners += 1
|
||||
_request_listener_slot()
|
||||
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
|
||||
cg.add(paren.register_listener(var))
|
||||
return var
|
||||
@@ -418,7 +396,21 @@ async def register_raw_client(
|
||||
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
|
||||
will not be compiled in if this is the only registration method used.
|
||||
"""
|
||||
_get_registration_counts().clients += 1
|
||||
_request_client_slot()
|
||||
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
|
||||
cg.add(paren.register_client(var))
|
||||
return var
|
||||
|
||||
|
||||
async def register_scanner_state_listener(
|
||||
var: cg.SafeExpType, config: ConfigType
|
||||
) -> cg.SafeExpType:
|
||||
"""Register a listener for scanner state changes.
|
||||
|
||||
The slot request here is what sizes the tracker's listener storage; a
|
||||
build with no registrations compiles the storage out entirely.
|
||||
"""
|
||||
_request_scanner_state_listener_slot()
|
||||
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
|
||||
cg.add(paren.add_scanner_state_listener(var))
|
||||
return var
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::esp32_ble_tracker {
|
||||
|
||||
@@ -36,27 +36,6 @@ static const char *const TAG = "esp32_ble_tracker";
|
||||
|
||||
ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
const char *client_state_to_string(ClientState state) {
|
||||
switch (state) {
|
||||
case ClientState::INIT:
|
||||
return "INIT";
|
||||
case ClientState::DISCONNECTING:
|
||||
return "DISCONNECTING";
|
||||
case ClientState::IDLE:
|
||||
return "IDLE";
|
||||
case ClientState::DISCOVERED:
|
||||
return "DISCOVERED";
|
||||
case ClientState::CONNECTING:
|
||||
return "CONNECTING";
|
||||
case ClientState::CONNECTED:
|
||||
return "CONNECTED";
|
||||
case ClientState::ESTABLISHED:
|
||||
return "ESTABLISHED";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void ESP32BLETracker::setup() {
|
||||
@@ -443,9 +422,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i
|
||||
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
|
||||
this->scanner_state_ = state;
|
||||
this->state_version_++;
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
|
||||
for (auto *listener : this->scanner_state_listeners_) {
|
||||
listener->on_scanner_state(state);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESP32BLETracker::dump_config() {
|
||||
@@ -499,6 +480,8 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
|
||||
if (this->parse_advertisements_) {
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
ESPBTDevice device;
|
||||
// The historical ingest keeps the raw scan-result fields populated for
|
||||
// external components.
|
||||
device.parse_scan_rst(scan_result);
|
||||
|
||||
bool found = false;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include <array>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -19,6 +18,7 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
@@ -89,22 +89,11 @@ struct ClientStateCounts {
|
||||
bool operator!=(const ClientStateCounts &other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
enum class ClientState : uint8_t {
|
||||
// Connection is allocated
|
||||
INIT,
|
||||
// Client is disconnecting
|
||||
DISCONNECTING,
|
||||
// Connection is idle, no device detected.
|
||||
IDLE,
|
||||
// Device advertisement found.
|
||||
DISCOVERED,
|
||||
// Connection in progress.
|
||||
CONNECTING,
|
||||
// Initial connection established.
|
||||
CONNECTED,
|
||||
// The client and sub-clients have completed setup.
|
||||
ESTABLISHED,
|
||||
};
|
||||
// The client connection state types are owned by the platform-neutral
|
||||
// ble_device_base layer; re-exported here for backward compatibility.
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
using ConnectionType = ble_device_base::ConnectionType;
|
||||
using ble_device_base::client_state_to_string;
|
||||
|
||||
enum class ScannerState {
|
||||
// Scanner is idle, init state
|
||||
@@ -129,21 +118,6 @@ class BLEScannerStateListener {
|
||||
virtual void on_scanner_state(ScannerState state) = 0;
|
||||
};
|
||||
|
||||
// Helper function to convert ClientState to string
|
||||
const char *client_state_to_string(ClientState state);
|
||||
|
||||
enum class ConnectionType : uint8_t {
|
||||
// The default connection type, we hold all the services in ram
|
||||
// for the duration of the connection.
|
||||
V1,
|
||||
// The client has a cache of the services and mtu so we should not
|
||||
// fetch them again
|
||||
V3_WITH_CACHE,
|
||||
// The client does not need the services and mtu once we send them
|
||||
// so we should wipe them from memory as soon as we send them
|
||||
V3_WITHOUT_CACHE
|
||||
};
|
||||
|
||||
/// Base class for BLE GATT clients that connect to remote devices.
|
||||
///
|
||||
/// State Change Tracking Design:
|
||||
@@ -243,7 +217,10 @@ class ESP32BLETracker final : public Component,
|
||||
this->raw_advertisement_callback_ = callback;
|
||||
}
|
||||
ble_device_base::HubCapabilities get_capabilities() const override {
|
||||
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true};
|
||||
// scan_mode_switch is false: the mode is driven through this tracker's own
|
||||
// API (set_scan_active + restart), not the neutral request_scan_mode().
|
||||
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
|
||||
/* scan_mode_switch = */ false};
|
||||
}
|
||||
void get_adapter_mac(uint8_t out[6]) override;
|
||||
bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; }
|
||||
@@ -265,10 +242,15 @@ class ESP32BLETracker final : public Component,
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
/// Add a listener for scanner state changes
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
|
||||
/// Add a listener for scanner state changes. Only compiled when a consumer
|
||||
/// requested a slot in codegen: register through
|
||||
/// esp32_ble_tracker.register_scanner_state_listener() in your component's
|
||||
/// to_code, which requests the slot and emits this call.
|
||||
void add_scanner_state_listener(BLEScannerStateListener *listener) {
|
||||
this->scanner_state_listeners_.push_back(listener);
|
||||
}
|
||||
#endif
|
||||
ScannerState get_scanner_state() const { return this->scanner_state_; }
|
||||
|
||||
protected:
|
||||
@@ -335,7 +317,10 @@ class ESP32BLETracker final : public Component,
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
|
||||
#endif
|
||||
std::vector<BLEScannerStateListener *> scanner_state_listeners_;
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
|
||||
StaticVector<BLEScannerStateListener *, ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT>
|
||||
scanner_state_listeners_;
|
||||
#endif
|
||||
// Parsed listeners registered through the neutral BLEHub contract (migrated
|
||||
// sensors); dispatched alongside listeners_.
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
|
||||
namespace esphome::esp32_camera_web_server {
|
||||
|
||||
static const int IMAGE_REQUEST_TIMEOUT = 5000;
|
||||
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";
|
||||
|
||||
#define PART_BOUNDARY "123456789000000000000987654321"
|
||||
@@ -113,10 +115,31 @@ std::shared_ptr<esphome::camera::CameraImage> CameraWebServer::wait_for_image_()
|
||||
std::shared_ptr<esphome::camera::CameraImage> image;
|
||||
image.swap(this->image_);
|
||||
|
||||
if (!image) {
|
||||
// retry as we might still be fetching image
|
||||
xSemaphoreTake(this->semaphore_, IMAGE_REQUEST_TIMEOUT / portTICK_PERIOD_MS);
|
||||
if (image)
|
||||
return image;
|
||||
|
||||
// Keep waiting until a frame really shows up, rather than trusting a single
|
||||
// take() to mean one is there.
|
||||
//
|
||||
// on_camera_image() gives the semaphore for every frame it accepts, but the
|
||||
// swap above hands frames out without taking it, so as soon as the camera is
|
||||
// faster than this task for one frame the (binary) semaphore is left
|
||||
// signalled by a frame that has already been consumed. The next take() then
|
||||
// returns immediately with nothing to swap in, and the caller reports a lost
|
||||
// frame and closes the stream -- after an arbitrary number of good frames,
|
||||
// which is exactly when the camera happens to fall behind for one iteration.
|
||||
//
|
||||
// running_ is re-checked on every pass so a shutdown or a client that went
|
||||
// away is noticed straight away instead of after the full timeout.
|
||||
const uint32_t start = millis();
|
||||
while (this->running_) {
|
||||
uint32_t elapsed = millis() - start;
|
||||
if (elapsed >= IMAGE_REQUEST_TIMEOUT)
|
||||
break;
|
||||
xSemaphoreTake(this->semaphore_, pdMS_TO_TICKS(IMAGE_REQUEST_TIMEOUT - elapsed));
|
||||
image.swap(this->image_);
|
||||
if (image)
|
||||
break;
|
||||
}
|
||||
|
||||
return image;
|
||||
@@ -170,8 +193,14 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
|
||||
return res;
|
||||
}
|
||||
|
||||
uint32_t last_frame = millis();
|
||||
uint32_t frames = 0;
|
||||
// Frame statistics are aggregated over STREAM_STATS_INTERVAL rather than
|
||||
// logged per frame. A line per frame comes out of this (non-main) task tens
|
||||
// of times a second, and formatting and buffering it costs more than the
|
||||
// stream it is reporting on.
|
||||
uint32_t stats_since = millis();
|
||||
uint32_t stats_frames = 0;
|
||||
uint32_t stats_bytes = 0;
|
||||
|
||||
camera::Camera::instance()->start_stream(esphome::camera::WEB_REQUESTER);
|
||||
|
||||
@@ -179,7 +208,10 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
|
||||
auto image = this->wait_for_image_();
|
||||
|
||||
if (!image) {
|
||||
ESP_LOGW(TAG, "STREAM: failed to acquire frame");
|
||||
// A shutdown is not a lost frame: wait_for_image_() returns empty as soon
|
||||
// as running_ clears, and the loop condition below ends the stream anyway.
|
||||
if (this->running_)
|
||||
ESP_LOGW(TAG, "STREAM: failed to acquire frame");
|
||||
res = ESP_FAIL;
|
||||
}
|
||||
if (res == ESP_OK) {
|
||||
@@ -194,14 +226,29 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
|
||||
}
|
||||
if (res == ESP_OK) {
|
||||
frames++;
|
||||
int64_t frame_time = millis() - last_frame;
|
||||
last_frame = millis();
|
||||
|
||||
ESP_LOGD(TAG, "MJPG: %" PRIu32 "B %" PRIu32 "ms (%.1ffps)", (uint32_t) image->get_data_length(),
|
||||
(uint32_t) frame_time, 1000.0 / (uint32_t) frame_time);
|
||||
stats_frames++;
|
||||
stats_bytes += image->get_data_length();
|
||||
uint32_t elapsed = millis() - stats_since;
|
||||
if (elapsed >= STREAM_STATS_INTERVAL) {
|
||||
ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed,
|
||||
stats_bytes / stats_frames, stats_frames);
|
||||
stats_since = millis();
|
||||
stats_frames = 0;
|
||||
stats_bytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report whatever did not fill a whole interval, so a stream that only ran for
|
||||
// a second or two still says what it managed rather than nothing at all.
|
||||
if (stats_frames > 0) {
|
||||
uint32_t elapsed = millis() - stats_since;
|
||||
if (elapsed == 0)
|
||||
elapsed = 1;
|
||||
ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed,
|
||||
stats_bytes / stats_frames, stats_frames);
|
||||
}
|
||||
|
||||
if (!frames) {
|
||||
res = httpd_send_all(req, STREAM_ERROR, strlen(STREAM_ERROR));
|
||||
}
|
||||
|
||||
@@ -255,11 +255,11 @@ light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index
|
||||
break;
|
||||
}
|
||||
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
|
||||
uint8_t white = this->is_wrgb_ ? 0 : 3;
|
||||
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
|
||||
|
||||
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
|
||||
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
|
||||
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
|
||||
return {this->buf_ + (index * multiplier) + r + (white <= r),
|
||||
this->buf_ + (index * multiplier) + g + (white <= g),
|
||||
this->buf_ + (index * multiplier) + b + (white <= b),
|
||||
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
|
||||
&this->effect_data_[index],
|
||||
&this->correction_};
|
||||
@@ -295,11 +295,22 @@ void ESP32RMTLEDStripLightOutput::dump_config() {
|
||||
rgb_order = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
if (this->is_rgbw_ || this->is_wrgb_) {
|
||||
char rgbw_order[5];
|
||||
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
|
||||
uint8_t rgb_index = 0;
|
||||
for (uint8_t i = 0; i < 4; i++) {
|
||||
rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++];
|
||||
}
|
||||
rgbw_order[4] = '\0';
|
||||
ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" RGB Order: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
}
|
||||
|
||||
float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -52,6 +52,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
|
||||
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
|
||||
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
|
||||
void set_rgbw_order(uint8_t white_index) {
|
||||
this->is_rgbw_ = true;
|
||||
this->is_wrgb_ = false;
|
||||
this->white_index_ = white_index;
|
||||
}
|
||||
void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; }
|
||||
void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; }
|
||||
|
||||
@@ -91,6 +96,8 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
|
||||
uint16_t num_leds_;
|
||||
bool is_rgbw_{false};
|
||||
bool is_wrgb_{false};
|
||||
// An index after the RGB channels makes offset adjustment a no-op for three-channel strips.
|
||||
uint8_t white_index_{3};
|
||||
bool use_dma_{false};
|
||||
bool use_psram_{false};
|
||||
bool invert_out_{false};
|
||||
|
||||
@@ -20,6 +20,7 @@ from esphome.const import (
|
||||
CONF_RMT_SYMBOLS,
|
||||
CONF_USE_DMA,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,6 +63,7 @@ CHIPSETS = {
|
||||
}
|
||||
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
CONF_RGBW_ORDER = "rgbw_order"
|
||||
CONF_BIT0_HIGH = "bit0_high"
|
||||
CONF_BIT0_LOW = "bit0_low"
|
||||
CONF_BIT1_HIGH = "bit1_high"
|
||||
@@ -70,6 +72,26 @@ CONF_RESET_HIGH = "reset_high"
|
||||
CONF_RESET_LOW = "reset_low"
|
||||
|
||||
|
||||
def _validate_rgbw_order(value: str) -> str:
|
||||
value = cv.string(value).upper()
|
||||
if len(value) != 4 or set(value) != set("RGBW"):
|
||||
raise cv.Invalid("RGBW order must be a permutation of RGBW")
|
||||
return value
|
||||
|
||||
|
||||
def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]:
|
||||
return rgbw_order.replace("W", ""), rgbw_order.index("W")
|
||||
|
||||
|
||||
def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType:
|
||||
if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or "
|
||||
f"'{CONF_IS_WRGB}'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
esp32.only_on_variant(
|
||||
unsupported=list(esp32_rmt.VARIANTS_NO_RMT),
|
||||
@@ -80,7 +102,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
|
||||
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
|
||||
cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order,
|
||||
cv.SplitDefault(
|
||||
CONF_RMT_SYMBOLS,
|
||||
esp32=192,
|
||||
@@ -130,6 +153,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
|
||||
cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER),
|
||||
_validate_rgbw_order_exclusivity,
|
||||
)
|
||||
|
||||
|
||||
@@ -173,9 +198,14 @@ async def to_code(config):
|
||||
)
|
||||
)
|
||||
|
||||
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
|
||||
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
|
||||
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
|
||||
if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None:
|
||||
rgb_order, white_index = _split_rgbw_order(rgbw_order)
|
||||
cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order]))
|
||||
cg.add(var.set_rgbw_order(white_index))
|
||||
else:
|
||||
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
|
||||
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
|
||||
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
|
||||
cg.add(var.set_use_psram(config[CONF_USE_PSRAM]))
|
||||
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
|
||||
if CONF_USE_DMA in config:
|
||||
|
||||
@@ -299,6 +299,7 @@ async def to_code(config):
|
||||
"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")
|
||||
@@ -451,6 +452,7 @@ def copy_files() -> None:
|
||||
"exclude_updater",
|
||||
"exclude_waveform",
|
||||
"remove_float_scanf",
|
||||
"relocate_ratetable",
|
||||
):
|
||||
copy_file_if_changed(
|
||||
dir / f"{script}.py.script",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
# Re-exported from the shared definition; here it indexes the BOARDS
|
||||
# metadata dicts, whose entries in boards.py spell the literal.
|
||||
from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import
|
||||
from esphome.core import CORE
|
||||
|
||||
KEY_ESP8266 = "esp8266"
|
||||
@@ -8,7 +12,6 @@ CONF_RESTORE_FROM_FLASH = "restore_from_flash"
|
||||
CONF_EARLY_PIN_INIT = "early_pin_init"
|
||||
CONF_ENABLE_SERIAL = "enable_serial"
|
||||
CONF_ENABLE_SERIAL1 = "enable_serial1"
|
||||
KEY_FLASH_SIZE = "flash_size"
|
||||
KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa
|
||||
|
||||
# Move the NONOS SDK wifi rate tables from flash to DRAM
|
||||
#
|
||||
# libnet80211.a ships its 802.11b/11g rate tables in the .irom.text section
|
||||
# of ieee80211_phy.o (440 bytes of pure data, no relocations). The Arduino
|
||||
# core linker script places .irom.text in flash, but the SDK reads these
|
||||
# tables with byte loads and ets_memcpy from the wifi RX path while parsing
|
||||
# beacons. Byte access to flash-mapped memory from that context misbehaves
|
||||
# and crashes with StoreProhibited in ROM memcpy (PC 0x4000df64):
|
||||
#
|
||||
# scan_parse_beacon -> cnx_update_bss_more -> ieee80211_phy_init
|
||||
# -> ieee80211_setup_ratetable -> ets_memcpy -> crash
|
||||
#
|
||||
# See https://github.com/espressif/ESP8266_NONOS_SDK/issues/320 (1000+
|
||||
# reports). The SDK is abandoned so the fix from
|
||||
# https://github.com/espressif/ESP8266_NONOS_SDK/pull/345 was never merged;
|
||||
# we apply the same linker rule here: place ieee80211_phy.o's .irom.text
|
||||
# inside the DRAM .data output section so the tables are copied to RAM at
|
||||
# boot. Costs 440 bytes of DRAM.
|
||||
#
|
||||
# The rule is inserted into the working linker script that PlatformIO
|
||||
# generates in the build directory (local.eagle.app.v6.common.ld). SDK
|
||||
# package files are never modified.
|
||||
|
||||
import re
|
||||
from os.path import join
|
||||
|
||||
RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
|
||||
# Match the whole line: "_data_start" is also a substring of the
|
||||
# "_dport0_data_start" line in the earlier .dport0.data section
|
||||
ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
|
||||
|
||||
|
||||
def relocate_ratetable(source, target, env):
|
||||
"""Insert the rate table DRAM rule into the generated linker script.
|
||||
|
||||
Runs as a pre-action of the link step; the linker script is a declared
|
||||
dependency of the elf, so it has already been generated at this point.
|
||||
"""
|
||||
ld_path = join(env.subst("$BUILD_DIR"), "ld", "local.eagle.app.v6.common.ld")
|
||||
with open(ld_path, encoding="utf-8") as f:
|
||||
contents = f.read()
|
||||
|
||||
if RULE in contents:
|
||||
return # Already patched (incremental build)
|
||||
|
||||
match = ANCHOR.search(contents)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
f"ESPHome: '_data_start' anchor not found in {ld_path}; "
|
||||
"cannot apply wifi rate table DRAM relocation "
|
||||
"(has the Arduino core linker script changed?)"
|
||||
)
|
||||
|
||||
insert_pos = match.end()
|
||||
patched = (
|
||||
contents[:insert_pos]
|
||||
+ "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
|
||||
+ f"\n {RULE}"
|
||||
+ contents[insert_pos:]
|
||||
)
|
||||
with open(ld_path, "w", encoding="utf-8") as f:
|
||||
f.write(patched)
|
||||
print("ESPHome: Relocated wifi rate tables to DRAM (fixes beacon parse crash)")
|
||||
|
||||
|
||||
# Register the callback to run before the link step
|
||||
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", relocate_ratetable)
|
||||
@@ -13,7 +13,7 @@ from esphome.const import (
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WIFI,
|
||||
)
|
||||
from esphome.core import HexInt
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
@@ -151,6 +151,10 @@ async def to_code(config):
|
||||
|
||||
cg.add_define("USE_ESPNOW")
|
||||
cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE])
|
||||
|
||||
if CONF_WIFI 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):
|
||||
cg.add(var.set_wifi_channel(wifi_channel))
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -75,6 +74,7 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
|
||||
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
|
||||
// allocate() returned non-null, the queue cannot be full.
|
||||
|
||||
// Wake main loop immediately to process ESP-NOW send event
|
||||
App.wake_loop_threadsafe();
|
||||
// Re-enable and wake the main loop to process the ESP-NOW send event
|
||||
global_esp_now->enable_loop_soon_any_context();
|
||||
}
|
||||
|
||||
void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) {
|
||||
@@ -101,6 +101,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,6 +110,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,8 +122,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int
|
||||
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
|
||||
// allocate() returned non-null, the queue cannot be full.
|
||||
|
||||
// Wake main loop immediately to process ESP-NOW receive event
|
||||
App.wake_loop_threadsafe();
|
||||
// Re-enable and wake the main loop to process the ESP-NOW receive event
|
||||
global_esp_now->enable_loop_soon_any_context();
|
||||
}
|
||||
|
||||
ESPNowComponent::ESPNowComponent() { global_esp_now = this; }
|
||||
@@ -156,6 +158,11 @@ bool ESPNowComponent::is_wifi_enabled() {
|
||||
}
|
||||
|
||||
void ESPNowComponent::setup() {
|
||||
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->add_connect_state_listener(this);
|
||||
}
|
||||
#endif
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable_();
|
||||
} else {
|
||||
@@ -163,6 +170,19 @@ void ESPNowComponent::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
|
||||
void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::span<const uint8_t, 6> bssid) {
|
||||
if (ssid.empty()) {
|
||||
return; // Disconnected; the channel is only meaningful while associated
|
||||
}
|
||||
uint8_t old_channel = this->wifi_channel_;
|
||||
this->get_wifi_channel();
|
||||
if (this->wifi_channel_ != old_channel) {
|
||||
ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void ESPNowComponent::enable() {
|
||||
if (this->state_ == ESPNOW_STATE_ENABLED)
|
||||
return;
|
||||
@@ -254,15 +274,6 @@ void ESPNowComponent::apply_wifi_channel() {
|
||||
}
|
||||
|
||||
void ESPNowComponent::loop() {
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) {
|
||||
int32_t new_channel = wifi::global_wifi_component->get_wifi_channel();
|
||||
if (new_channel != this->wifi_channel_) {
|
||||
ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel);
|
||||
this->wifi_channel_ = new_channel;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Process received packets
|
||||
ESPNowPacket *packet = this->receive_packet_queue_.pop();
|
||||
while (packet != nullptr) {
|
||||
@@ -348,6 +359,15 @@ void ESPNowComponent::loop() {
|
||||
if (send_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped);
|
||||
}
|
||||
|
||||
// Nothing left to do; sleep until a callback or send() re-enables the loop.
|
||||
// A packet in flight (current_send_packet_) needs no loop time even when more
|
||||
// packets are queued behind it: the send callback re-enables the loop when
|
||||
// the result arrives, and the SENT event handler above starts the next send.
|
||||
if (this->receive_packet_queue_.empty() &&
|
||||
(this->current_send_packet_ != nullptr || this->send_packet_queue_.empty())) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t ESPNowComponent::get_wifi_channel() {
|
||||
@@ -390,6 +410,9 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl
|
||||
packet->load_data(peer_address, payload, size, 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
|
||||
// (any-context variant so callers off the main loop are safe too)
|
||||
this->enable_loop_soon_any_context();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -9,6 +10,10 @@
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
#include "espnow_packet.h"
|
||||
|
||||
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
#include <esp_mac.h>
|
||||
@@ -88,7 +93,11 @@ class ESPNowBroadcastHandler {
|
||||
virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0;
|
||||
};
|
||||
|
||||
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
|
||||
class ESPNowComponent final : public Component, public wifi::WiFiConnectStateListener {
|
||||
#else
|
||||
class ESPNowComponent final : public Component {
|
||||
#endif
|
||||
public:
|
||||
ESPNowComponent();
|
||||
void setup() override;
|
||||
@@ -114,6 +123,11 @@ class ESPNowComponent final : public Component {
|
||||
|
||||
void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; }
|
||||
|
||||
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
|
||||
// WiFiConnectStateListener interface: refresh the cached channel after each (re)connect
|
||||
void on_wifi_connect_state(StringRef ssid, std::span<const uint8_t, 6> bssid) override;
|
||||
#endif
|
||||
|
||||
void enable();
|
||||
void disable();
|
||||
bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; };
|
||||
|
||||
@@ -43,6 +43,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_HOST")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
# 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.add_define("ESPHOME_BOARD", "host")
|
||||
|
||||
@@ -27,6 +27,9 @@ class HostPreferences final : public PreferencesMixin<HostPreferences> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// One-shot read of a stored preference by key, without allocating a backend
|
||||
bool load_from_key(uint32_t type, uint8_t *data, size_t len) { return this->load(type, data, len); }
|
||||
|
||||
bool load(uint32_t key, uint8_t *data, size_t len) {
|
||||
if (len > 255)
|
||||
return false;
|
||||
|
||||
@@ -154,12 +154,8 @@ 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());
|
||||
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
|
||||
&data.get_raw_data());
|
||||
}
|
||||
#endif
|
||||
return false; // Don't consume the event, allow other listeners to process it
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -21,7 +22,6 @@ DEPENDENCIES = ["ld2450"]
|
||||
|
||||
CONF_MOVING_TARGET_COUNT = "moving_target_count"
|
||||
CONF_STILL_TARGET_COUNT = "still_target_count"
|
||||
CONF_TARGET_COUNT = "target_count"
|
||||
|
||||
ICON_ACCOUNT_GROUP = "mdi:account-group"
|
||||
ICON_ACCOUNT_SWITCH = "mdi:account-switch"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_WAKEUP_PIN
|
||||
|
||||
from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE
|
||||
|
||||
CODEOWNERS = ["@hepter"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
MULTI_CONF = True
|
||||
|
||||
ld6002b_ns = cg.esphome_ns.namespace("ld6002b")
|
||||
LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice)
|
||||
|
||||
|
||||
def _validate_wakeup_options(config):
|
||||
"""Reject wake options that would silently do nothing.
|
||||
|
||||
Runs before the schema so the defaults for the keys below have not been
|
||||
filled in yet and an explicit user value is still distinguishable from one.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
if CONF_WAKEUP_PIN in config:
|
||||
return config
|
||||
for key in (CONF_AUTO_WAKE, CONF_WAKEUP_PULSE):
|
||||
if key in config:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' requires '{CONF_WAKEUP_PIN}' to be configured", path=[key]
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
_validate_wakeup_options,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(LD6002BComponent),
|
||||
cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(
|
||||
CONF_WAKEUP_PULSE, default="50ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"ld6002b",
|
||||
baud_rate=115200,
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
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 wakeup_pin_config := config.get(CONF_WAKEUP_PIN):
|
||||
pin = await cg.gpio_pin_expression(wakeup_pin_config)
|
||||
cg.add(var.set_wakeup_pin(pin))
|
||||
|
||||
cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds))
|
||||
|
||||
cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE]))
|
||||
@@ -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_TARGET, DEVICE_CLASS_OCCUPANCY
|
||||
|
||||
from . import LD6002BComponent
|
||||
from .const import CONF_LD6002B_ID, MAX_TARGETS
|
||||
|
||||
DEPENDENCIES = ["ld6002b"]
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
),
|
||||
}
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
)
|
||||
for i in range(MAX_TARGETS)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
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))
|
||||
|
||||
for i in range(MAX_TARGETS):
|
||||
if target_config := config.get(f"target_{i + 1}"):
|
||||
sens = await binary_sensor.new_binary_sensor(target_config)
|
||||
cg.add(hub.set_target_presence_binary_sensor(i, sens))
|
||||
@@ -0,0 +1,8 @@
|
||||
CONF_AUTO_WAKE = "auto_wake"
|
||||
CONF_CLUSTER_ID = "cluster_id"
|
||||
CONF_DOPPLER_INDEX = "doppler_index"
|
||||
CONF_LD6002B_ID = "ld6002b_id"
|
||||
CONF_WAKEUP_PULSE = "wakeup_pulse"
|
||||
CONF_Z = "z"
|
||||
|
||||
MAX_TARGETS = 3
|
||||
@@ -0,0 +1,590 @@
|
||||
#include "ld6002b.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ld6002b {
|
||||
|
||||
static const char *const TAG = "ld6002b";
|
||||
|
||||
static constexpr uint8_t TF_SOF = 0x01;
|
||||
static constexpr uint32_t SETUP_DELAY_MS = 100;
|
||||
|
||||
// Command/message types
|
||||
static constexpr uint16_t TYPE_CONTROL = 0x0201;
|
||||
|
||||
static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04;
|
||||
|
||||
// Control command values for TYPE_CONTROL
|
||||
static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06;
|
||||
static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07;
|
||||
static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08;
|
||||
static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09;
|
||||
|
||||
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
static const char *control_command_name(uint32_t command) {
|
||||
switch (command) {
|
||||
case CMD_POINT_CLOUD_ON:
|
||||
return "point_cloud_on";
|
||||
case CMD_POINT_CLOUD_OFF:
|
||||
return "point_cloud_off";
|
||||
case CMD_TARGET_DISPLAY_ON:
|
||||
return "target_display_on";
|
||||
case CMD_TARGET_DISPLAY_OFF:
|
||||
return "target_display_off";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast<uint16_t>(data[0]) << 8) | data[1]; }
|
||||
|
||||
uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) {
|
||||
return static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
|
||||
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
|
||||
}
|
||||
|
||||
int32_t LD6002BComponent::read_int32_le(const uint8_t *data) {
|
||||
uint32_t raw = read_u32_le(data);
|
||||
int32_t value;
|
||||
std::memcpy(&value, &raw, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
float LD6002BComponent::read_f32_le(const uint8_t *data) {
|
||||
uint32_t raw = read_u32_le(data);
|
||||
float value;
|
||||
std::memcpy(&value, &raw, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) {
|
||||
data[0] = value & 0xFF;
|
||||
data[1] = (value >> 8) & 0xFF;
|
||||
data[2] = (value >> 16) & 0xFF;
|
||||
data[3] = (value >> 24) & 0xFF;
|
||||
}
|
||||
|
||||
void LD6002BComponent::setup() {
|
||||
// One allocation for the component lifetime; the parser reuses it for the header and every payload.
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
this->data_buf_ = allocator.allocate(DEFAULT_MAX_DATA_LEN);
|
||||
if (this->data_buf_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Failed to allocate frame buffer"));
|
||||
return;
|
||||
}
|
||||
if (this->wakeup_pin_ != nullptr) {
|
||||
this->wakeup_pin_->setup();
|
||||
this->wakeup_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
this->set_timeout(SETUP_DELAY_MS, [this]() {
|
||||
bool want_target_stream = false;
|
||||
#ifdef USE_SENSOR
|
||||
want_target_stream = want_target_stream || this->target_count_sensor_ != nullptr;
|
||||
if (!want_target_stream) {
|
||||
for (const auto &target : this->targets_) {
|
||||
if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr ||
|
||||
target.cluster_id != nullptr) {
|
||||
want_target_stream = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr;
|
||||
if (!want_target_stream) {
|
||||
for (auto *sensor : this->target_presence_) {
|
||||
if (sensor != nullptr) {
|
||||
want_target_stream = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (want_target_stream) {
|
||||
this->send_control_command_(CMD_TARGET_DISPLAY_ON);
|
||||
}
|
||||
|
||||
this->send_control_command_(CMD_POINT_CLOUD_OFF);
|
||||
});
|
||||
}
|
||||
|
||||
void LD6002BComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"HLK-LD6002B:\n"
|
||||
" Auto wake: %s",
|
||||
this->auto_wake_ ? "true" : "false");
|
||||
if (this->wakeup_pin_ != nullptr) {
|
||||
LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_);
|
||||
ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_);
|
||||
}
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Target Count", this->target_count_sensor_);
|
||||
for (auto &target : this->targets_) {
|
||||
LOG_SENSOR(" ", "Target X", target.x);
|
||||
LOG_SENSOR(" ", "Target Y", target.y);
|
||||
LOG_SENSOR(" ", "Target Z", target.z);
|
||||
LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx);
|
||||
LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_);
|
||||
for (uint8_t i = 0; i < MAX_TARGETS; i++) {
|
||||
LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD6002BComponent::loop() {
|
||||
while (this->available()) {
|
||||
uint8_t byte = this->read();
|
||||
this->parse_byte_(byte);
|
||||
}
|
||||
this->process_command_queue_();
|
||||
}
|
||||
|
||||
void LD6002BComponent::reset_parser_() {
|
||||
this->parse_state_ = ParseState::SOF;
|
||||
this->header_pos_ = 0;
|
||||
this->header_xor_ = 0;
|
||||
this->data_len_ = 0;
|
||||
this->data_pos_ = 0;
|
||||
this->data_xor_ = 0;
|
||||
this->discard_remaining_ = 0;
|
||||
this->frame_oversize_ = false;
|
||||
}
|
||||
|
||||
void LD6002BComponent::parse_byte_(uint8_t byte) {
|
||||
switch (this->parse_state_) {
|
||||
case ParseState::DISCARD:
|
||||
// discard_remaining_ is unsigned: an unguarded decrement at zero would swallow 4 GB of stream.
|
||||
if (this->discard_remaining_ > 0) {
|
||||
this->discard_remaining_--;
|
||||
}
|
||||
if (this->discard_remaining_ == 0) {
|
||||
this->reset_parser_();
|
||||
}
|
||||
return;
|
||||
case ParseState::SOF:
|
||||
if (byte != TF_SOF)
|
||||
return;
|
||||
this->header_pos_ = 0;
|
||||
this->header_xor_ = 0;
|
||||
this->header_xor_ ^= byte;
|
||||
this->parse_state_ = ParseState::HEADER;
|
||||
return;
|
||||
case ParseState::HEADER:
|
||||
if (this->header_pos_ < 6) {
|
||||
this->data_buf_[this->header_pos_] = byte;
|
||||
this->header_xor_ ^= byte;
|
||||
this->header_pos_++;
|
||||
if (this->header_pos_ == 6) {
|
||||
this->frame_id_ = read_u16_be(this->data_buf_);
|
||||
this->data_len_ = read_u16_be(this->data_buf_ + 2);
|
||||
this->frame_type_ = read_u16_be(this->data_buf_ + 4);
|
||||
// The length is only trustworthy once the header checksum has been verified, so just
|
||||
// remember that the frame is oversized and let the HCK state act on it.
|
||||
this->frame_oversize_ = this->data_len_ > DEFAULT_MAX_DATA_LEN;
|
||||
this->parse_state_ = ParseState::HCK;
|
||||
}
|
||||
}
|
||||
return;
|
||||
case ParseState::HCK: {
|
||||
uint8_t expected = static_cast<uint8_t>(~this->header_xor_);
|
||||
if (byte != expected) {
|
||||
ESP_LOGV(TAG, "Header checksum mismatch");
|
||||
this->reset_parser_();
|
||||
return;
|
||||
}
|
||||
if (this->frame_oversize_) {
|
||||
ESP_LOGW(TAG, "Frame too large: %u", this->data_len_);
|
||||
// The header is verified, so the length can be trusted: skip the payload and its checksum.
|
||||
this->discard_remaining_ = static_cast<uint32_t>(this->data_len_) + 1;
|
||||
this->parse_state_ = ParseState::DISCARD;
|
||||
return;
|
||||
}
|
||||
if (this->data_len_ == 0) {
|
||||
this->handle_frame_(this->frame_type_, nullptr, 0);
|
||||
this->reset_parser_();
|
||||
} else {
|
||||
this->data_pos_ = 0;
|
||||
this->data_xor_ = 0;
|
||||
this->parse_state_ = ParseState::DATA;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case ParseState::DATA:
|
||||
this->data_buf_[this->data_pos_++] = byte;
|
||||
this->data_xor_ ^= byte;
|
||||
if (this->data_pos_ >= this->data_len_) {
|
||||
this->parse_state_ = ParseState::DCK;
|
||||
}
|
||||
return;
|
||||
case ParseState::DCK: {
|
||||
uint8_t expected = static_cast<uint8_t>(~this->data_xor_);
|
||||
if (byte == expected) {
|
||||
this->handle_frame_(this->frame_type_, this->data_buf_, this->data_len_);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Data checksum mismatch");
|
||||
}
|
||||
this->reset_parser_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) {
|
||||
this->last_traffic_ms_ = millis();
|
||||
if (this->stale_ack_count_ > 0 && millis() - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) {
|
||||
this->stale_ack_count_ = 0;
|
||||
}
|
||||
// ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command.
|
||||
if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) {
|
||||
this->stale_ack_count_--;
|
||||
ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type,
|
||||
this->frame_id_);
|
||||
return;
|
||||
}
|
||||
if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) {
|
||||
ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_);
|
||||
// This settles one expected reply; the rest stay owed and become the debt for the next command.
|
||||
this->send_generation_++;
|
||||
this->stale_ack_type_ = type;
|
||||
this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast<uint8_t>(this->acks_expected_ - 1) : 0;
|
||||
this->stale_ack_ms_ = millis();
|
||||
this->command_active_ = false;
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
this->process_command_queue_();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case TYPE_REPORT_TARGET:
|
||||
this->handle_target_report_(data, len);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 4)
|
||||
return;
|
||||
|
||||
uint32_t target_num = read_u32_le(data);
|
||||
uint16_t available = (len - 4) / TARGET_DATA_LEN;
|
||||
// Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent".
|
||||
const uint32_t reported = std::min<uint32_t>(target_num, available);
|
||||
uint8_t count = static_cast<uint8_t>(std::min<uint32_t>(reported, MAX_TARGETS));
|
||||
|
||||
// The module re-sorts its array by cluster id, so slots key on the id to track the person.
|
||||
std::array<int32_t, MAX_TARGETS> wire_cluster{};
|
||||
std::array<bool, MAX_TARGETS> wire_placed{};
|
||||
std::array<bool, MAX_TARGETS> slot_seen{};
|
||||
std::array<uint8_t, MAX_TARGETS> slot_wire{};
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16;
|
||||
wire_cluster[i] = static_cast<int32_t>(read_u32_le(data + cluster_offset));
|
||||
}
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
for (uint8_t s = 0; s < MAX_TARGETS; s++) {
|
||||
if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) {
|
||||
slot_seen[s] = true;
|
||||
wire_placed[i] = true;
|
||||
slot_wire[s] = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uint8_t s = 0; s < MAX_TARGETS; s++) {
|
||||
if (!slot_seen[s]) {
|
||||
this->slot_occupied_[s] = false;
|
||||
}
|
||||
}
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (wire_placed[i]) {
|
||||
continue;
|
||||
}
|
||||
for (uint8_t s = 0; s < MAX_TARGETS; s++) {
|
||||
if (!this->slot_occupied_[s]) {
|
||||
this->slot_occupied_[s] = true;
|
||||
this->slot_cluster_[s] = wire_cluster[i];
|
||||
slot_wire[s] = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
if (this->target_count_sensor_ != nullptr) {
|
||||
if (reported != this->last_target_count_) {
|
||||
this->target_count_sensor_->publish_state(reported);
|
||||
this->last_target_count_ = reported;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
this->target_presence_any_ = (reported > 0);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->presence_binary_sensor_ != nullptr) {
|
||||
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
|
||||
}
|
||||
#endif
|
||||
|
||||
for (uint8_t i = 0; i < MAX_TARGETS; i++) {
|
||||
bool has_target = this->slot_occupied_[i];
|
||||
if (has_target) {
|
||||
#ifdef USE_SENSOR
|
||||
uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN);
|
||||
float x = read_f32_le(data + offset + 0);
|
||||
float y = read_f32_le(data + offset + 4);
|
||||
float z = read_f32_le(data + offset + 8);
|
||||
int32_t dop_idx = read_int32_le(data + offset + 12);
|
||||
int32_t cluster_id = this->slot_cluster_[i];
|
||||
TargetSensors &target = this->targets_[i];
|
||||
if (target.x != nullptr) {
|
||||
target.x->publish_state(x);
|
||||
}
|
||||
if (target.y != nullptr) {
|
||||
target.y->publish_state(y);
|
||||
}
|
||||
if (target.z != nullptr) {
|
||||
target.z->publish_state(z);
|
||||
}
|
||||
if (target.dop_idx != nullptr) {
|
||||
target.dop_idx->publish_state(static_cast<float>(dop_idx));
|
||||
}
|
||||
if (target.cluster_id != nullptr) {
|
||||
if (!this->last_cluster_id_valid_[i] || cluster_id != this->last_cluster_id_[i]) {
|
||||
target.cluster_id->publish_state(static_cast<float>(cluster_id));
|
||||
this->last_cluster_id_[i] = cluster_id;
|
||||
this->last_cluster_id_valid_[i] = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
#ifdef USE_SENSOR
|
||||
TargetSensors &target = this->targets_[i];
|
||||
if (this->last_target_presence_[i]) {
|
||||
if (target.x != nullptr) {
|
||||
target.x->publish_state(NAN);
|
||||
}
|
||||
if (target.y != nullptr) {
|
||||
target.y->publish_state(NAN);
|
||||
}
|
||||
if (target.z != nullptr) {
|
||||
target.z->publish_state(NAN);
|
||||
}
|
||||
if (target.dop_idx != nullptr) {
|
||||
target.dop_idx->publish_state(NAN);
|
||||
}
|
||||
if (target.cluster_id != nullptr) {
|
||||
target.cluster_id->publish_state(NAN);
|
||||
}
|
||||
// The slot is free: the next person's id is new even when it repeats this one.
|
||||
this->last_cluster_id_valid_[i] = false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->target_presence_[i] != nullptr) {
|
||||
// publish_state() already skips unchanged states, no manual de-dup needed.
|
||||
this->target_presence_[i]->publish_state(has_target);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
this->last_target_presence_[i] = has_target;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) {
|
||||
if (len > CMD_MAX_DATA_LEN) {
|
||||
ESP_LOGW(TAG, "Command data too large: %u", len);
|
||||
return;
|
||||
}
|
||||
if (this->cmd_count_ >= CMD_QUEUE_SIZE) {
|
||||
ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type);
|
||||
return;
|
||||
}
|
||||
|
||||
PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_];
|
||||
cmd.type = type;
|
||||
cmd.len = len;
|
||||
if (len > 0 && data != nullptr) {
|
||||
std::memcpy(cmd.data.data(), data, len);
|
||||
}
|
||||
|
||||
this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE;
|
||||
this->cmd_count_++;
|
||||
this->process_command_queue_();
|
||||
}
|
||||
|
||||
void LD6002BComponent::process_command_queue_() {
|
||||
uint32_t now = millis();
|
||||
if (this->command_active_) {
|
||||
// A sleeping module consumes the opening attempt as its wake-up instead of answering it.
|
||||
const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS;
|
||||
if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) {
|
||||
const uint32_t active_control_command =
|
||||
(this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4)
|
||||
? read_u32_le(this->active_command_.data.data())
|
||||
: 0;
|
||||
if (this->retries_left_ > 0) {
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
if (active_control_command != 0) {
|
||||
ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining",
|
||||
control_command_name(active_control_command), active_control_command, this->retries_left_);
|
||||
} else {
|
||||
// Writes without a control subcommand (hold delay, z-range) had no retry trace at all.
|
||||
ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type,
|
||||
this->retries_left_);
|
||||
}
|
||||
#endif
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len);
|
||||
this->retries_left_--;
|
||||
} else {
|
||||
if (active_control_command != 0) {
|
||||
ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type,
|
||||
active_control_command);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type);
|
||||
}
|
||||
// A reply may still be in flight for the attempt we just gave up on, so carry one over as
|
||||
// debt rather than clearing the ledger, or that late ACK would retire the successor. Only
|
||||
// one: reaching this point means nothing was answered at all, so the older attempts are
|
||||
// speculative, and carrying them would swallow the successor's own replies.
|
||||
const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) +
|
||||
(this->acks_expected_ > 0 ? 1 : 0);
|
||||
this->stale_ack_type_ = this->active_command_.type;
|
||||
this->stale_ack_count_ = static_cast<uint8_t>(std::min<uint16_t>(owed, 255));
|
||||
this->stale_ack_ms_ = now;
|
||||
this->send_generation_++;
|
||||
this->command_active_ = false;
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->cmd_count_ == 0)
|
||||
return;
|
||||
|
||||
this->active_command_ = this->cmd_queue_[this->cmd_head_];
|
||||
this->cmd_head_ = (this->cmd_head_ + 1) % CMD_QUEUE_SIZE;
|
||||
this->cmd_count_--;
|
||||
|
||||
this->send_generation_++;
|
||||
this->retries_left_ = CMD_MAX_RETRIES;
|
||||
this->command_active_ = true;
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
this->attempts_sent_ = 0;
|
||||
this->acks_expected_ = 0;
|
||||
if (this->stale_ack_type_ != this->active_command_.type) {
|
||||
this->stale_ack_count_ = 0;
|
||||
}
|
||||
this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len);
|
||||
}
|
||||
|
||||
void LD6002BComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) {
|
||||
this->send_command_internal_(type, data, len, true);
|
||||
}
|
||||
|
||||
void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) {
|
||||
if (len > CMD_MAX_DATA_LEN) {
|
||||
ESP_LOGW(TAG, "Command data too large: %u", len);
|
||||
if (track) {
|
||||
// Release the slot: an unwritten command is never acked and never times out.
|
||||
this->command_active_ = false;
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Anonymous timeouts never replace each other; with a pulse already pending the module is waking anyway.
|
||||
if (this->auto_wake_ && this->wakeup_pin_ != nullptr && !this->wake_pulse_pending_) {
|
||||
// Snapshot the payload: the deferred write must not depend on state a completing command changes.
|
||||
if (len > 0 && data != nullptr) {
|
||||
std::memcpy(this->wake_scratch_.data(), data, len);
|
||||
}
|
||||
this->wake_pulse_pending_ = true;
|
||||
this->wakeup_pin_->digital_write(false);
|
||||
const uint8_t generation = this->send_generation_;
|
||||
this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() {
|
||||
this->wakeup_pin_->digital_write(true);
|
||||
this->wake_pulse_pending_ = false;
|
||||
// Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been
|
||||
// retired must not transmit: the frame would land after its successor and be booked to it.
|
||||
if (track && generation != this->send_generation_) {
|
||||
return;
|
||||
}
|
||||
this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this->write_frame_(type, data, len, track);
|
||||
}
|
||||
|
||||
void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) {
|
||||
uint16_t frame_id = this->next_frame_id_++ & 0x7FFF;
|
||||
frame_id |= 0x8000;
|
||||
|
||||
uint8_t header_xor = 0;
|
||||
auto write_header = [&](uint8_t b) {
|
||||
this->write_byte(b);
|
||||
header_xor ^= b;
|
||||
};
|
||||
|
||||
write_header(TF_SOF);
|
||||
write_header((frame_id >> 8) & 0xFF);
|
||||
write_header(frame_id & 0xFF);
|
||||
write_header((len >> 8) & 0xFF);
|
||||
write_header(len & 0xFF);
|
||||
write_header((type >> 8) & 0xFF);
|
||||
write_header(type & 0xFF);
|
||||
|
||||
this->write_byte(static_cast<uint8_t>(~header_xor));
|
||||
|
||||
if (len > 0 && data != nullptr) {
|
||||
uint8_t data_xor = 0;
|
||||
for (uint8_t i = 0; i < len; i++) {
|
||||
this->write_byte(data[i]);
|
||||
data_xor ^= data[i];
|
||||
}
|
||||
this->write_byte(static_cast<uint8_t>(~data_xor));
|
||||
}
|
||||
const uint32_t now = millis();
|
||||
if (track) {
|
||||
// A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered.
|
||||
if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) {
|
||||
this->acks_expected_++;
|
||||
}
|
||||
this->last_send_ms_ = now;
|
||||
this->command_sent_ = true;
|
||||
this->attempts_sent_++;
|
||||
}
|
||||
this->last_traffic_ms_ = now;
|
||||
}
|
||||
|
||||
void LD6002BComponent::send_control_command_(uint32_t command) {
|
||||
uint8_t data[4];
|
||||
write_u32_le(data, command);
|
||||
this->queue_command_(TYPE_CONTROL, data, sizeof(data));
|
||||
}
|
||||
|
||||
} // namespace esphome::ld6002b
|
||||
@@ -0,0 +1,191 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/gpio.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::ld6002b {
|
||||
|
||||
static constexpr uint8_t MAX_TARGETS = 3;
|
||||
static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024;
|
||||
// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes.
|
||||
static constexpr size_t CMD_MAX_DATA_LEN = 28;
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
struct TargetSensors {
|
||||
sensor::Sensor *x{nullptr};
|
||||
sensor::Sensor *y{nullptr};
|
||||
sensor::Sensor *z{nullptr};
|
||||
sensor::Sensor *dop_idx{nullptr};
|
||||
sensor::Sensor *cluster_id{nullptr};
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
|
||||
void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; }
|
||||
void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; }
|
||||
void set_auto_wake(bool enable) { this->auto_wake_ = enable; }
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; }
|
||||
|
||||
void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->targets_[target].x = sensor;
|
||||
}
|
||||
void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->targets_[target].y = sensor;
|
||||
}
|
||||
void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->targets_[target].z = sensor;
|
||||
}
|
||||
void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->targets_[target].dop_idx = sensor;
|
||||
}
|
||||
void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->targets_[target].cluster_id = sensor;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; }
|
||||
void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) {
|
||||
if (target >= MAX_TARGETS)
|
||||
return;
|
||||
this->target_presence_[target] = sensor;
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD };
|
||||
|
||||
struct PendingCommand {
|
||||
uint16_t type{0};
|
||||
uint8_t len{0};
|
||||
std::array<uint8_t, CMD_MAX_DATA_LEN> data{};
|
||||
};
|
||||
|
||||
void parse_byte_(uint8_t byte);
|
||||
void reset_parser_();
|
||||
void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len);
|
||||
void handle_target_report_(const uint8_t *data, uint16_t len);
|
||||
|
||||
void queue_command_(uint16_t type, const uint8_t *data, uint8_t len);
|
||||
void process_command_queue_();
|
||||
void send_command_(uint16_t type, const uint8_t *data, uint8_t len);
|
||||
void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
|
||||
void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
|
||||
void send_control_command_(uint32_t command);
|
||||
|
||||
static uint16_t read_u16_be(const uint8_t *data);
|
||||
static uint32_t read_u32_le(const uint8_t *data);
|
||||
static int32_t read_int32_le(const uint8_t *data);
|
||||
static float read_f32_le(const uint8_t *data);
|
||||
static void write_u32_le(uint8_t *data, uint32_t value);
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
std::array<TargetSensors, MAX_TARGETS> targets_{};
|
||||
sensor::Sensor *target_count_sensor_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
|
||||
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
|
||||
#endif
|
||||
|
||||
GPIOPin *wakeup_pin_{nullptr};
|
||||
uint32_t wakeup_pulse_ms_{50};
|
||||
bool auto_wake_{true};
|
||||
|
||||
ParseState parse_state_{ParseState::SOF};
|
||||
uint8_t header_pos_{0};
|
||||
uint8_t header_xor_{0};
|
||||
uint16_t data_len_{0};
|
||||
uint16_t frame_type_{0};
|
||||
uint16_t frame_id_{0};
|
||||
uint16_t data_pos_{0};
|
||||
uint8_t data_xor_{0};
|
||||
uint32_t discard_remaining_{0};
|
||||
bool frame_oversize_{false};
|
||||
uint8_t *data_buf_{nullptr};
|
||||
uint16_t next_frame_id_{0};
|
||||
|
||||
// Sized for the boot burst: with every platform configured, setup() enqueues
|
||||
// roughly ten GET/config commands back to back before the first ack lands.
|
||||
static constexpr uint8_t CMD_QUEUE_SIZE = 16;
|
||||
static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300;
|
||||
// A sleeping module consumes the first frame to wake and answers only the one after it.
|
||||
static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600;
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
std::array<PendingCommand, CMD_QUEUE_SIZE> cmd_queue_{};
|
||||
uint8_t cmd_head_{0};
|
||||
uint8_t cmd_tail_{0};
|
||||
uint8_t cmd_count_{0};
|
||||
bool command_active_{false};
|
||||
bool command_sent_{false};
|
||||
PendingCommand active_command_{};
|
||||
// Frame a pending wake pulse will write, snapshotted because active_command_ may move on first.
|
||||
std::array<uint8_t, CMD_MAX_DATA_LEN> wake_scratch_{};
|
||||
bool wake_pulse_pending_{false};
|
||||
uint8_t retries_left_{0};
|
||||
uint32_t last_send_ms_{0};
|
||||
// Last frame seen in either direction; any traffic keeps the module awake.
|
||||
uint32_t last_traffic_ms_{0};
|
||||
// Frames transmitted for the command in flight, including retries; drives the retry budget.
|
||||
uint8_t attempts_sent_{0};
|
||||
// Subset of those the module can actually answer: a frame that woke it is consumed, not replied to.
|
||||
uint8_t acks_expected_{0};
|
||||
// ACKs still owed for superseded attempts; they carry no id, only their arrival order.
|
||||
uint16_t stale_ack_type_{0};
|
||||
uint8_t stale_ack_count_{0};
|
||||
// When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK.
|
||||
uint32_t stale_ack_ms_{0};
|
||||
// Bumped whenever the active command changes, so a deferred send can tell it was retired.
|
||||
uint8_t send_generation_{0};
|
||||
|
||||
// Which person owns each target_N slot, so a slot survives the module re-sorting its array.
|
||||
std::array<int32_t, MAX_TARGETS> slot_cluster_{};
|
||||
std::array<bool, MAX_TARGETS> slot_occupied_{};
|
||||
|
||||
bool target_presence_any_{false};
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
std::array<bool, MAX_TARGETS> last_target_presence_{}; // one-shot NAN clear for target sensors
|
||||
// A cluster id names a person, so like the counts it is published on change, not per frame.
|
||||
std::array<int32_t, MAX_TARGETS> last_cluster_id_{};
|
||||
std::array<bool, MAX_TARGETS> last_cluster_id_valid_{};
|
||||
uint32_t last_target_count_{0xFFFFFFFF};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ld6002b
|
||||
@@ -0,0 +1,105 @@
|
||||
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_X,
|
||||
CONF_Y,
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_METER,
|
||||
)
|
||||
|
||||
from . import LD6002BComponent
|
||||
from .const import (
|
||||
CONF_CLUSTER_ID,
|
||||
CONF_DOPPLER_INDEX,
|
||||
CONF_LD6002B_ID,
|
||||
CONF_Z,
|
||||
MAX_TARGETS,
|
||||
)
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
filters=_VALUE_SENSOR_FILTERS,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
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))
|
||||
|
||||
for i in range(MAX_TARGETS):
|
||||
if target_config := config.get(f"target_{i + 1}"):
|
||||
if x_config := target_config.get(CONF_X):
|
||||
sens = await sensor.new_sensor(x_config)
|
||||
cg.add(hub.set_target_x_sensor(i, sens))
|
||||
if y_config := target_config.get(CONF_Y):
|
||||
sens = await sensor.new_sensor(y_config)
|
||||
cg.add(hub.set_target_y_sensor(i, sens))
|
||||
if z_config := target_config.get(CONF_Z):
|
||||
sens = await sensor.new_sensor(z_config)
|
||||
cg.add(hub.set_target_z_sensor(i, sens))
|
||||
if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX):
|
||||
sens = await sensor.new_sensor(doppler_index_config)
|
||||
cg.add(hub.set_target_dop_idx_sensor(i, sens))
|
||||
if cluster_id_config := target_config.get(CONF_CLUSTER_ID):
|
||||
sens = await sensor.new_sensor(cluster_id_config)
|
||||
cg.add(hub.set_target_cluster_id_sensor(i, sens))
|
||||
@@ -461,6 +461,8 @@ async def component_to_code(config):
|
||||
# setup board config
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_build_flag("-DUSE_LIBRETINY")
|
||||
# FlashDB finds stored preferences by key, so preference key migration is possible
|
||||
cg.add_define("USE_PREFERENCE_KEY_LOOKUP")
|
||||
cg.add_build_flag(f"-DUSE_{config[CONF_COMPONENT_ID].upper()}")
|
||||
cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
|
||||
@@ -65,6 +65,10 @@ def _set_core_data(config):
|
||||
return config
|
||||
|
||||
|
||||
# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared
|
||||
# by every LibreTiny platform, and prepending this platform's _set_core_data
|
||||
# onto the shared object would run it for every platform's validation once two
|
||||
# platform modules are imported in one process (device-builder, tests).
|
||||
CONFIG_SCHEMA = {SCHEMA}
|
||||
|
||||
PIN_SCHEMA = {PIN_SCHEMA}
|
||||
@@ -117,7 +121,7 @@ VAR_GPIO_PIN = "validate_pin"
|
||||
VAR_GPIO_USAGE = "validate_usage"
|
||||
|
||||
# lines for code snippets
|
||||
SCHEMA_BASE = "libretiny.BASE_SCHEMA"
|
||||
SCHEMA_BASE = "libretiny.BASE_SCHEMA.extend({})"
|
||||
SCHEMA_EXTRA = f"libretiny.BASE_SCHEMA.extend({VAR_SCHEMA})"
|
||||
PIN_SCHEMA_BASE = "libretiny.gpio.BASE_PIN_SCHEMA"
|
||||
PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})"
|
||||
|
||||
@@ -70,12 +70,21 @@ void LibreTinyPreferences::open() {
|
||||
}
|
||||
|
||||
ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) {
|
||||
auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
pref->db = &this->db;
|
||||
pref->blob = &this->blob;
|
||||
pref->key = type;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type)));
|
||||
}
|
||||
|
||||
return ESPPreferenceObject(pref);
|
||||
LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) {
|
||||
LibreTinyPreferenceBackend backend;
|
||||
backend.key = type;
|
||||
backend.db = &this->db;
|
||||
backend.blob = &this->blob;
|
||||
return backend;
|
||||
}
|
||||
|
||||
bool LibreTinyPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) {
|
||||
LibreTinyPreferenceBackend backend = this->make_backend_(type);
|
||||
return backend.load(data, len);
|
||||
}
|
||||
|
||||
bool LibreTinyPreferences::sync() {
|
||||
|
||||
@@ -16,6 +16,8 @@ class LibreTinyPreferences final : public PreferencesMixin<LibreTinyPreferences>
|
||||
return this->make_preference(length, type);
|
||||
}
|
||||
ESPPreferenceObject make_preference(size_t length, uint32_t type);
|
||||
/// One-shot read of a stored preference by key, without allocating a backend
|
||||
bool load_from_key(uint32_t type, uint8_t *data, size_t len);
|
||||
bool sync();
|
||||
bool reset();
|
||||
|
||||
@@ -23,6 +25,7 @@ class LibreTinyPreferences final : public PreferencesMixin<LibreTinyPreferences>
|
||||
struct fdb_blob blob;
|
||||
|
||||
protected:
|
||||
LibreTinyPreferenceBackend make_backend_(uint32_t type);
|
||||
bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
# Once per registered scan listener; sizes the controller's StaticVector
|
||||
# listener storage.
|
||||
request_scan_listener_slot = cg.slot_counter("LN882H_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// ln882h_ble.cpp
|
||||
//
|
||||
// BLE controller support for the LN882H (LibreTiny lightning-ln882h family) —
|
||||
// the platform analog of esp32_ble / rp2040_ble. Owns the one-time stack
|
||||
// bring-up (rw_init + the ln_* app init sequence) and the controller BLE
|
||||
// address (persistent KV entry, WiFi-MAC-derived once). Consumers
|
||||
// (ln882h_ble_tracker) build on this component and contain no SDK calls of
|
||||
// their own.
|
||||
// the platform analog of esp32_ble / rp2040_ble. Owns everything that talks to
|
||||
// the LN882H BLE SDK:
|
||||
// - one-time stack bring-up (rw_init + the ln_* app init sequence),
|
||||
// - the controller BLE address (persistent KV entry, WiFi-MAC-derived once),
|
||||
// - the raw controller scan primitives (ln_ble_scan_start/stop),
|
||||
// - the scan-report ring: the SDK's rw-task event callback decodes each
|
||||
// report (including the controller's RSSI sign quirk) into a fixed pool
|
||||
// and pushes it on a lock-free SPSC queue; loop() drains, dispatches on
|
||||
// the main task and returns reports to the pool — the same EventPool +
|
||||
// LockFreeQueue handoff esp32_ble uses, zero allocation at steady state.
|
||||
// Consumers contain no SDK calls of their own.
|
||||
//
|
||||
// BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE
|
||||
// stack itself is compiled and linked by the LibreTiny lightning-ln882h builder
|
||||
@@ -51,6 +57,9 @@ void ln_ble_scan_actv_creat(void);
|
||||
void ln_ble_scan_start(void *scan_param);
|
||||
void ln_ble_scan_stop(void);
|
||||
|
||||
using ble_evt_cb_t = void (*)(void *param);
|
||||
void ln_ble_evt_mgr_reg_evt(int evt_id, ble_evt_cb_t cb);
|
||||
|
||||
} // extern "C"
|
||||
|
||||
// ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is
|
||||
@@ -64,9 +73,10 @@ static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byt
|
||||
// CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block
|
||||
// BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports
|
||||
// GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type /
|
||||
// gapm_dup_filter_pol / gapm_scan_prop
|
||||
// gapm_dup_filter_pol / gapm_scan_prop / gapm_adv_report_info
|
||||
// ---------------------------------------------------------------------------
|
||||
static constexpr uint32_t CLK_G_BLE = 1u << 0;
|
||||
static constexpr int BLE_EVT_ID_SCAN_REPORT = 3;
|
||||
|
||||
// WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK
|
||||
// exposes no symbolic name for this register; the address and value replicate
|
||||
@@ -93,6 +103,20 @@ static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2;
|
||||
static constexpr uint8_t GAPM_DUP_FILT_DIS = 0;
|
||||
// gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3.
|
||||
static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0;
|
||||
static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2;
|
||||
|
||||
// GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info).
|
||||
// 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement),
|
||||
// 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv).
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_ADV_LEG = 1;
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
|
||||
// Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan
|
||||
// response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT).
|
||||
static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1u << 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK struct layouts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout,
|
||||
// with the pad byte explicit so the whole block zero-initialises.
|
||||
@@ -110,6 +134,29 @@ struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirror
|
||||
static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout");
|
||||
static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t");
|
||||
|
||||
// Scan report delivered by the BLE_EVT_ID_SCAN_REPORT event. Layout verified on
|
||||
// hardware against the prebuilt BLE stack LibreTiny links: its report carries no
|
||||
// PHY fields and stores the advertisement data inline (flexible array), unlike
|
||||
// the newer upstream SDK header (which adds phy_prim/phy_second and a data pointer).
|
||||
struct ble_scan_report_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name
|
||||
uint8_t actv_idx;
|
||||
uint8_t info;
|
||||
uint8_t trans_addr_type;
|
||||
uint8_t trans_addr[6];
|
||||
uint8_t target_addr_type;
|
||||
uint8_t target_addr[6];
|
||||
int8_t tx_pwr;
|
||||
int8_t rssi; // signed dBm, range -127..+20 (ble_evt_scan_report_t from ln_ble_event_manager.h)
|
||||
uint16_t length;
|
||||
uint8_t data[0];
|
||||
};
|
||||
// Pin the layout of the hand-mirrored report struct too: the comment above
|
||||
// notes a newer SDK header uses a different layout (PHY fields + data pointer),
|
||||
// so silent drift here would corrupt every decoded advertisement.
|
||||
static_assert(sizeof(ble_scan_report_t) == 20, "ble_scan_report_t must match the linked BLE stack's layout");
|
||||
static_assert(offsetof(ble_scan_report_t, length) == 18, "unexpected padding in ble_scan_report_t");
|
||||
static_assert(offsetof(ble_scan_report_t, data) == 20, "advertisement data must follow the header inline");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// __sprintf weak stub
|
||||
//
|
||||
@@ -133,11 +180,95 @@ namespace esphome::ln882h_ble {
|
||||
|
||||
static const char *const 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.
|
||||
static LN882HBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// Scan parameter blocks handed to ln_ble_scan_start(void *). static storage:
|
||||
// the SDK may retain the pointer past the call (the block travels into a GAPM
|
||||
// message consumed later by the rw task), so a stack-local would leave the
|
||||
// controller reading a dead frame. Double-buffered: consecutive starts (the
|
||||
// enable() probe followed by the first real scan, or a parameter restart)
|
||||
// alternate blocks, so a rewrite can never race a previous block that is still
|
||||
// in flight — correct under either reading of SDK retention. All writers run
|
||||
// on the main task.
|
||||
static le_scan_parameters_t s_scan_params[2]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint8_t s_scan_params_idx = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static le_scan_parameters_t *next_scan_params() {
|
||||
s_scan_params_idx ^= 1;
|
||||
return &s_scan_params[s_scan_params_idx];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan-report event callback — runs in the SDK's rw task context.
|
||||
// Decode the report (hardware-verified struct layout + the RSSI sign fix),
|
||||
// copy it into the queue and return; all dispatch happens in loop() on the
|
||||
// main task.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void ble_scan_callback(void *param) {
|
||||
if (s_ble == nullptr || param == nullptr)
|
||||
return;
|
||||
const auto *info = reinterpret_cast<const ble_scan_report_t *>(param);
|
||||
|
||||
// Only legacy framing is supported (see scan_start(): legacy 1M PHY only):
|
||||
// an extended report does not fit BLEScanReport::data and would reach
|
||||
// consumers as a truncated legacy frame. Reject before allocating so these
|
||||
// do not burn pool slots either.
|
||||
const uint8_t report_type = info->info & 0x07;
|
||||
if (report_type != GAPM_REPORT_TYPE_ADV_LEG && report_type != GAPM_REPORT_TYPE_SCAN_RSP_LEG) {
|
||||
s_ble->count_rejected_report();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill the pool slot in place (the bk72xx_ble shape): no report on the rw
|
||||
// task's stack — its size is fixed by the prebuilt stack — one copy of the
|
||||
// payload instead of two, and only data_len bytes ever leave this frame.
|
||||
BLEScanReport *slot = s_ble->allocate_scan_report();
|
||||
if (slot == nullptr)
|
||||
return; // no slot — counted as dropped in allocate_scan_report()
|
||||
|
||||
// BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with
|
||||
// a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented
|
||||
// -127..+20 dBm maximum. Recover it by negating any value above +20 (verified
|
||||
// on-device: the out-of-range positives cluster at the magnitude of each
|
||||
// device's real readings). This is the ONLY LN882H-specific RSSI handling —
|
||||
// downstream the value is used exactly like on ESP32.
|
||||
const int8_t raw = info->rssi;
|
||||
|
||||
memcpy(slot->mac, info->trans_addr, 6);
|
||||
slot->rssi = (raw > 20) ? static_cast<int8_t>(-raw) : raw;
|
||||
slot->addr_type = info->trans_addr_type;
|
||||
slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG;
|
||||
slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0;
|
||||
slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast<uint8_t>(info->length)
|
||||
: static_cast<uint8_t>(sizeof(slot->data));
|
||||
memcpy(slot->data, info->data, slot->data_len);
|
||||
|
||||
s_ble->push_scan_report(slot);
|
||||
}
|
||||
|
||||
BLEScanReport *LN882HBLE::allocate_scan_report() {
|
||||
BLEScanReport *slot = this->report_pool_.allocate();
|
||||
if (slot == nullptr) {
|
||||
// No slot: pool exhausted (queue full) or the pool's on-demand RAM
|
||||
// allocation failed; count and drop either way.
|
||||
this->report_queue_.increment_dropped_count();
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
void LN882HBLE::push_scan_report(BLEScanReport *report) {
|
||||
// Cannot fail: the pool is sized to the queue capacity.
|
||||
this->report_queue_.push(report);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLE::setup() {
|
||||
s_ble = this;
|
||||
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
|
||||
// the stack is up. The KV load also happens here (no stack dependency).
|
||||
this->resolve_mac_();
|
||||
@@ -175,23 +306,70 @@ void LN882HBLE::enable() {
|
||||
delay(10);
|
||||
|
||||
// Prime the scan activity with a short probe start/stop — the SDK's scan
|
||||
// manager completes activity creation on the first start.
|
||||
// static: its address is handed to ln_ble_scan_start(void *), which may
|
||||
// retain it past this call.
|
||||
static le_scan_parameters_t probe_p{};
|
||||
probe_p.type = GAPM_SCAN_TYPE_OBSERVER;
|
||||
probe_p.prop = GAPM_SCAN_PROP_PHY_1M_BIT;
|
||||
probe_p.dup_filt_pol = GAPM_DUP_FILT_DIS;
|
||||
probe_p.scan_intv = 160;
|
||||
probe_p.scan_wd = 16;
|
||||
ln_ble_scan_start(&probe_p);
|
||||
// manager completes activity creation on the first start. Uses the shared
|
||||
// static parameter block (see s_scan_params for the lifetime rationale).
|
||||
le_scan_parameters_t *probe = next_scan_params();
|
||||
probe->type = GAPM_SCAN_TYPE_OBSERVER;
|
||||
probe->prop = GAPM_SCAN_PROP_PHY_1M_BIT;
|
||||
probe->dup_filt_pol = GAPM_DUP_FILT_DIS;
|
||||
probe->scan_intv = 160;
|
||||
probe->scan_wd = 16;
|
||||
ln_ble_scan_start(probe);
|
||||
delay(10);
|
||||
ln_ble_scan_stop();
|
||||
|
||||
// Register the scan-report event exactly once, after the event manager is up.
|
||||
// Repeated registration corrupts the SDK's event registry (verified on
|
||||
// hardware), which is why this lives here and not in scan_start().
|
||||
ln_ble_evt_mgr_reg_evt(BLE_EVT_ID_SCAN_REPORT, ble_scan_callback);
|
||||
|
||||
this->state_ = BLEComponentState::ACTIVE;
|
||||
ESP_LOGD(TAG, "BLE stack initialised");
|
||||
}
|
||||
|
||||
void LN882HBLE::loop() {
|
||||
// Log dropped reports before the empty-queue return: a drop can also mean
|
||||
// EventPool::allocate() failed on heap exhaustion, and that can happen with
|
||||
// the queue empty — from the very first report on. Checking here keeps that
|
||||
// failure visible instead of producing a scanner that is silently dead.
|
||||
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
|
||||
if (dropped > 0)
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped);
|
||||
// Drain the lock-free ring filled by the rw task; all per-report work runs
|
||||
// here on the main task, then the report returns to the pool.
|
||||
BLEScanReport *report = this->report_queue_.pop();
|
||||
if (report != nullptr) {
|
||||
this->reject_diagnosis_done_ = true;
|
||||
do {
|
||||
#ifdef LN882H_BLE_SCAN_LISTENER_COUNT
|
||||
for (auto *listener : this->scan_listeners_)
|
||||
listener->on_scan_report(*report);
|
||||
#endif
|
||||
this->report_pool_.release(report);
|
||||
} while ((report = this->report_queue_.pop()) != nullptr);
|
||||
}
|
||||
|
||||
// Rejected-report accounting AFTER the drain: a stray non-legacy frame
|
||||
// arriving ahead of the first good one must not latch the dead-scanner
|
||||
// warning; the threshold keeps one-off boot noise below it while a truly
|
||||
// dead scanner (~200 reports/s all rejected) crosses it within a second.
|
||||
// Avoid the sub-word CAS in the common case (LockFreeQueue's dropped-count
|
||||
// pattern): rejects are rare, the load is cheap.
|
||||
uint16_t rejected = this->rejected_reports_.load(std::memory_order_relaxed);
|
||||
if (rejected > 0) {
|
||||
rejected = this->rejected_reports_.exchange(0, std::memory_order_relaxed);
|
||||
if (!this->reject_diagnosis_done_) {
|
||||
this->rejected_before_delivery_ += rejected;
|
||||
if (this->rejected_before_delivery_ >= REJECTED_DEAD_SCANNER_THRESHOLD) {
|
||||
this->reject_diagnosis_done_ = true;
|
||||
ESP_LOGW(TAG, "Rejected %u scan reports before any was delivered - unexpected report encoding?",
|
||||
static_cast<unsigned>(this->rejected_before_delivery_));
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Rejected %u non-legacy scan reports", rejected);
|
||||
}
|
||||
}
|
||||
|
||||
void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); }
|
||||
|
||||
void LN882HBLE::dump_config() {
|
||||
@@ -246,6 +424,50 @@ void LN882HBLE::resolve_mac_() {
|
||||
memcpy(this->ble_mac_, bt_addr.addr, 6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller scan primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLE::scan_start(uint16_t interval, uint16_t window, bool active) {
|
||||
if (!this->is_active())
|
||||
this->enable();
|
||||
|
||||
if (this->scanning_) {
|
||||
// Already scanning - stop first so this call cleanly restarts with the new
|
||||
// parameters (re-entry guard). Give the GAPM stop the same settle time
|
||||
// enable() grants between consecutive GAPM operations before restarting.
|
||||
this->scan_stop();
|
||||
delay(10); // NOLINT — restart-only, mirrors enable()'s inter-operation settle
|
||||
}
|
||||
|
||||
// Double-buffered static block — see s_scan_params for the lifetime rationale.
|
||||
le_scan_parameters_t *p = next_scan_params();
|
||||
p->dup_filt_pol = GAPM_DUP_FILT_DIS;
|
||||
p->type = GAPM_SCAN_TYPE_OBSERVER;
|
||||
p->scan_intv = interval;
|
||||
p->scan_wd = window;
|
||||
// Legacy 1M PHY only: consumers size their buffers for legacy advertisements
|
||||
// (62 B); coded/extended PHY (up to 255 B) would be silently truncated.
|
||||
p->prop = GAPM_SCAN_PROP_PHY_1M_BIT;
|
||||
if (active)
|
||||
p->prop |= GAPM_SCAN_PROP_ACTIVE_1M_BIT;
|
||||
|
||||
ln_ble_scan_start(p);
|
||||
// ln_ble_scan_start() returns void, so this tracks the requested state, not a
|
||||
// confirmed one — a controller-side failure surfaces as an idle scanner (no
|
||||
// reports), which the consumer's start retry/backoff owns.
|
||||
this->scanning_ = true;
|
||||
}
|
||||
|
||||
void LN882HBLE::scan_stop() {
|
||||
// No-op when idle, as documented: the guard keeps a redundant SDK stop off
|
||||
// the GAPM path (scan_start()'s re-entry guard calls this while scanning).
|
||||
if (!this->scanning_)
|
||||
return;
|
||||
ln_ble_scan_stop();
|
||||
this->scanning_ = false;
|
||||
}
|
||||
|
||||
} // namespace esphome::ln882h_ble
|
||||
|
||||
#endif // USE_LN882H_BLE
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
#ifdef USE_LN882H_BLE
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ln882h_ble {
|
||||
@@ -16,9 +20,56 @@ enum class BLEComponentState : uint8_t {
|
||||
ACTIVE,
|
||||
};
|
||||
|
||||
/// One scan report from the controller, decoded from the SDK's rw-task event
|
||||
/// (RSSI already sign-corrected).
|
||||
struct BLEScanReport {
|
||||
uint8_t mac[6]; // as the controller delivers it (LSB-first)
|
||||
int8_t rssi; // signed dBm (-127..+20)
|
||||
uint8_t addr_type;
|
||||
bool is_scan_response; // report is a scan response (active scan)
|
||||
bool scannable; // advertisement may be followed by a scan response
|
||||
uint8_t data_len; // bytes valid in data[] (<= 62)
|
||||
// Each report carries ONE frame — a legacy advertisement (<=31 B) or a scan
|
||||
// response (<=31 B) — delivered split, exactly as the SDK reports them. The
|
||||
// TRACKER merges the pair into a single frame before any consumer sees it
|
||||
// (Bluedroid semantics, HubCapabilities::merges_scan_response). 62 is twice
|
||||
// the legacy maximum: defensive headroom for the data_len clamp, and the
|
||||
// same width as the merged framing downstream.
|
||||
uint8_t data[62];
|
||||
|
||||
// EventPool contract: nothing is heap-allocated inside a report.
|
||||
void release() {}
|
||||
};
|
||||
|
||||
/// Consumer interface for controller scan reports. on_scan_report() always runs
|
||||
/// on the ESPHome main task: reports are queued from the SDK's rw task and
|
||||
/// drained by the controller's loop(), so consumers never deal with cross-task
|
||||
/// state (the esp32_ble event-queue pattern).
|
||||
class BLEScanListener {
|
||||
public:
|
||||
virtual void on_scan_report(const BLEScanReport &report) = 0;
|
||||
|
||||
protected:
|
||||
~BLEScanListener() = default; // deletion via this interface is not part of the contract
|
||||
};
|
||||
|
||||
// Maximum reports buffered between the rw task and loop(). Sized from the
|
||||
// measured worst case, not copied: WiFi/BLE coexistence delays rw-task report
|
||||
// delivery by up to ~136 ms on this device (see the tracker's pending-adv
|
||||
// timeout rationale), and a busy 2.4 GHz environment delivers ~200-400
|
||||
// reports/s — a stall plus one loop() interval buffers ~30-60 reports, so 63
|
||||
// usable slots absorb it with margin. ~4.7 KB at high water, reached only
|
||||
// during such stalls.
|
||||
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
|
||||
|
||||
// Rejected frames tolerated before the first delivered report without
|
||||
// declaring the scanner dead (boot-time stray extended frames are normal).
|
||||
static constexpr uint16_t REJECTED_DEAD_SCANNER_THRESHOLD = 16;
|
||||
|
||||
class LN882HBLE final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
@@ -37,12 +88,64 @@ class LN882HBLE final : public Component {
|
||||
/// (the bk72xx sibling exposes the same accessor).
|
||||
void get_mac_lsb_first(uint8_t out[6]) const;
|
||||
|
||||
#ifdef LN882H_BLE_SCAN_LISTENER_COUNT
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
/// Storage is codegen-sized: the consumer's codegen requests a slot via
|
||||
/// request_scan_listener_slot(), which emits LN882H_BLE_SCAN_LISTENER_COUNT.
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
#endif
|
||||
|
||||
/// Start the controller scan. Interval/window are in BLE units (0.625 ms);
|
||||
/// active enables scan requests on the 1M PHY. Enables the stack first if
|
||||
/// needed. Scans the legacy 1M PHY only (extended/coded PHY advertisements
|
||||
/// exceed the legacy 62-byte framing consumers are sized for).
|
||||
void scan_start(uint16_t interval, uint16_t window, bool active);
|
||||
/// Stop the controller scan (no-op when not scanning).
|
||||
void scan_stop();
|
||||
|
||||
/// Internal, SDK rw-task event-callback context: allocate a pool slot for a
|
||||
/// scan report. Returns nullptr (and counts the drop) when the queue is full;
|
||||
/// the callback fills the slot in place — no intermediate copy.
|
||||
BLEScanReport *allocate_scan_report();
|
||||
/// Internal: hand a filled slot to the main-task queue (cannot fail — the
|
||||
/// pool is sized to the queue capacity).
|
||||
void push_scan_report(BLEScanReport *report);
|
||||
/// Internal, rw-task context: count a report rejected by the legacy-only
|
||||
/// filter, so a wrong assumption about the stack's report encoding shows up
|
||||
/// in verbose logs instead of as a scanner that silently reports nothing.
|
||||
void count_rejected_report() { this->rejected_reports_.fetch_add(1, std::memory_order_relaxed); }
|
||||
|
||||
protected:
|
||||
void resolve_mac_();
|
||||
|
||||
#ifdef LN882H_BLE_SCAN_LISTENER_COUNT
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiation —
|
||||
// the same StaticVector pattern as the tracker's ble_device_base listeners.
|
||||
StaticVector<BLEScanListener *, LN882H_BLE_SCAN_LISTENER_COUNT> scan_listeners_;
|
||||
#endif
|
||||
// Report ring: the SDK event callback (rw task) allocates a report from the
|
||||
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
|
||||
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
|
||||
// Overflow drops the NEWEST report (allocate fails, producer counts and
|
||||
// returns) — under a coexistence stall the freshest advertisements are lost
|
||||
// while queued ones drain. Deliberate: matches esp32_ble, and dropping from
|
||||
// the head would need consumer-side locking this design exists to avoid.
|
||||
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
|
||||
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
|
||||
// allocate() returns nullptr before push() can fail. This prevents leaking a
|
||||
// pool slot on a failed push and keeps release() off the producer path.
|
||||
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
|
||||
// Reports rejected by the legacy-only filter (rw-task producer, main-task
|
||||
// consumer via exchange in loop()).
|
||||
std::atomic<uint16_t> rejected_reports_{0};
|
||||
uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
bool enable_on_boot_{false};
|
||||
bool scanning_{false}; // controller scan running (re-entry guard for scan_start)
|
||||
// Dead-scanner diagnosis: done once a report is delivered or the one-shot
|
||||
// warning has fired, whichever comes first.
|
||||
bool reject_diagnosis_done_{false};
|
||||
uint32_t rejected_before_delivery_{0}; // drives the dead-scanner warning
|
||||
};
|
||||
|
||||
} // namespace esphome::ln882h_ble
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""LN882H BLE scanner implementing the ble_device_base BLEHub contract on
|
||||
top of the ln882h_ble controller. With continuous: false nothing scans until
|
||||
an explicit start_scan() call."""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, ln882h_ble, ota
|
||||
from esphome.components.ble_device_base import automation as ble_automation
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_ID,
|
||||
CONF_INTERVAL,
|
||||
CONF_MANUFACTURER_ID,
|
||||
CONF_ON_BLE_ADVERTISE,
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
|
||||
CONF_SERVICE_UUID,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_LN882H_BLE_ID = "ln882h_ble_id"
|
||||
|
||||
DEPENDENCIES = ["ln882x"]
|
||||
AUTO_LOAD = ["ble_device_base", "ln882h_ble"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
ble_device_base.register_hub_provider("ln882h_ble_tracker")
|
||||
|
||||
ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker")
|
||||
LN882HBLETracker = ln882h_ble_tracker_ns.class_(
|
||||
"LN882HBLETracker", ble_device_base.BLEHub, cg.Component
|
||||
)
|
||||
|
||||
StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action)
|
||||
StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action)
|
||||
|
||||
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
|
||||
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
|
||||
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
|
||||
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
|
||||
|
||||
|
||||
# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty).
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
|
||||
"100ms", window_default="50ms", supports_active=True
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(LN882HBLETracker),
|
||||
cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE),
|
||||
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
|
||||
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
|
||||
ESPBTAdvertiseTrigger
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEServiceDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEManufacturerDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
|
||||
BLEEndOfScanTrigger
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"ln882h_ble_tracker.start_scan",
|
||||
StartScanAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(LN882HBLETracker),
|
||||
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def start_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
|
||||
template_ = await cg.templatable(continuous, args, cg.bool_)
|
||||
cg.add(var.set_continuous(template_))
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"ln882h_ble_tracker.stop_scan",
|
||||
StopScanAction,
|
||||
automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(LN882HBLETracker),
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def stop_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
parent = await cg.get_variable(config[CONF_LN882H_BLE_ID])
|
||||
cg.add(var.set_parent(parent))
|
||||
# The tracker registers itself as a controller scan listener in setup();
|
||||
# request the codegen-sized StaticVector slot for it.
|
||||
ln882h_ble.request_scan_listener_slot()
|
||||
|
||||
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
scan = config[CONF_SCAN_PARAMETERS]
|
||||
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
|
||||
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
|
||||
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
|
||||
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
|
||||
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
|
||||
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
await ble_automation.advertise_trigger_to_code(conf, var)
|
||||
|
||||
for trigger_key, uuid_key, setter_prefix in (
|
||||
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
|
||||
(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_MANUFACTURER_ID,
|
||||
"set_manufacturer_uuid",
|
||||
),
|
||||
):
|
||||
for conf in config.get(trigger_key, []):
|
||||
await ble_automation.uuid_trigger_to_code(
|
||||
conf, var, uuid_key, setter_prefix
|
||||
)
|
||||
|
||||
for conf in config.get(CONF_ON_SCAN_END, []):
|
||||
await ble_automation.scan_end_trigger_to_code(conf, var)
|
||||
@@ -0,0 +1,47 @@
|
||||
// Scan-control actions for ln882h_ble_tracker. The automation triggers are the
|
||||
// neutral ble_device_base classes (ble_device_base/automation.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "ln882h_ble_tracker.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ln882h_ble_tracker {
|
||||
|
||||
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<LN882HBLETracker> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
void play(const Ts &...x) override {
|
||||
// With continuous: set, the action wins. Without it, the configured value
|
||||
// is used - stop_scan() clears the runtime flag permanently, so a bare
|
||||
// stop_scan/start_scan pair would otherwise never resume continuous mode.
|
||||
const bool want =
|
||||
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
|
||||
if (this->parent_->scan_running()) {
|
||||
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
|
||||
// the duration window here would let a repeated action keep a one-shot
|
||||
// scan alive forever. A real mode switch re-anchors so a change to
|
||||
// one-shot runs a full duration from now.
|
||||
if (want != this->parent_->scan_continuous()) {
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->restart_scan_duration();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->start_scan();
|
||||
}
|
||||
};
|
||||
|
||||
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<LN882HBLETracker> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->stop_scan(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::ln882h_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,381 @@
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "ln882h_ble_tracker.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ln882h_ble_tracker {
|
||||
|
||||
static const char *const TAG = "ln882h_ble_tracker";
|
||||
|
||||
static constexpr float BLE_SCAN_UNIT_MS = 0.625f;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLETracker::setup() {
|
||||
// Receive the controller's scan reports; the controller queues them from the
|
||||
// rw task and delivers here on the main task.
|
||||
this->parent_->register_scan_listener(this);
|
||||
// scan_running_ check: an on_boot start_scan action (priority 600) runs
|
||||
// before this setup() (200) and enable_loop() is a no-op pre-setup — parking
|
||||
// the loop here would strand that already-running scan.
|
||||
if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) {
|
||||
// Say so once: with continuous: false nothing scans until an explicit
|
||||
// start_scan() — silence here reads as a broken scanner.
|
||||
ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()");
|
||||
// Nothing to time until then; start_scan_() re-enables the loop.
|
||||
this->disable_loop();
|
||||
}
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update is in flight — on the single-core LN882H the
|
||||
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
|
||||
ota::OTAComponent *comp) {
|
||||
if (state == ota::OTA_STARTED) {
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->scan_running_before_ota_ = this->scan_running_;
|
||||
this->stop_scan();
|
||||
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
|
||||
// On success the device reboots, so restore only on a failed/aborted
|
||||
// update. Continuous mode resumes via loop()'s idle branch; a one-shot
|
||||
// scan that was running is restarted explicitly (bk72xx sibling parity —
|
||||
// stop_scan() cleared it and nothing else would bring it back).
|
||||
if (this->scan_continuous_before_ota_) {
|
||||
this->scan_continuous_ = true;
|
||||
this->enable_loop(); // stop_scan() disabled it; loop()'s idle branch restarts the scan
|
||||
} else if (this->scan_running_before_ota_) {
|
||||
this->start_scan();
|
||||
}
|
||||
this->scan_continuous_before_ota_ = false;
|
||||
this->scan_running_before_ota_ = false;
|
||||
}
|
||||
}
|
||||
#endif // USE_OTA_STATE_LISTENER
|
||||
|
||||
void LN882HBLETracker::loop() {
|
||||
if (this->pending_start_) {
|
||||
// A start_scan latched before the controller's setup(); safe now — loop()
|
||||
// only runs after every component set up.
|
||||
this->pending_start_ = false;
|
||||
if (!this->scan_running_) {
|
||||
this->start_scan_();
|
||||
}
|
||||
}
|
||||
// Flush pending scannable advertisements whose scan response never arrived
|
||||
// (device didn't answer / frame lost) — delivered unmerged after the timeout.
|
||||
// Main-task only, like every consumer of pending_adv_.
|
||||
const uint32_t now = millis();
|
||||
if (this->pending_count_ != 0) {
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
this->start_scan_();
|
||||
// start_scan_() re-anchors scan_period_start_ from a later millis() than
|
||||
// the cached `now`; resume the period timer next iteration.
|
||||
return;
|
||||
}
|
||||
// Period timer: once per scan_duration_ window, restart the controller scan
|
||||
// and fire on_scan_end(), mirroring esp32_ble_tracker::cleanup_scan_state_().
|
||||
// The restart is the recovery path for the coexistence failure documented in
|
||||
// the header. scan_start() re-enters cleanly on its own: it stops an
|
||||
// in-flight scan and grants the controller's 10 ms GAPM settle before
|
||||
// restarting — an explicit scan_stop() first would clear the controller's
|
||||
// re-entry guard and skip that settle.
|
||||
if (now - this->scan_period_start_ >= this->scan_duration_) {
|
||||
ESP_LOGD(TAG, "Scan period elapsed - restarting scan");
|
||||
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
|
||||
// Keep both clocks anchored to the restart: a runtime switch to
|
||||
// non-continuous then times out the current period, not the whole run.
|
||||
this->scan_start_time_ = now;
|
||||
this->end_scan_period_(now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
|
||||
// Restart is driven externally (e.g. wifi: on_connect:).
|
||||
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
|
||||
this->stop_scan_();
|
||||
}
|
||||
}
|
||||
|
||||
bool LN882HBLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and
|
||||
// no period reset: the scan logically continues, only the mode changes.
|
||||
if (this->scan_running_) {
|
||||
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void LN882HBLETracker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"LN882H BLE Tracker:\n"
|
||||
" Scan Duration: %" PRIu32 " s\n"
|
||||
" Scan Interval: %.0f ms (%" PRIu16 " BLE units)\n"
|
||||
" Scan Window: %.0f ms (%" PRIu16 " BLE units)\n"
|
||||
" Scan Type: %s\n"
|
||||
" Continuous Scanning: %s",
|
||||
this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_,
|
||||
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
|
||||
YESNO(this->scan_continuous_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adv/scan-response demux with Bluedroid-style merge: the LN controller
|
||||
// delivers the pair as separate reports; a scannable advertisement is held
|
||||
// until its scan response arrives and delivered as one merged frame.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) {
|
||||
if (report.is_scan_response) {
|
||||
this->deliver_scan_rsp_(report);
|
||||
return;
|
||||
}
|
||||
// Stash only while the scan runs: after a one-shot stop the loop is
|
||||
// disabled and nothing would sweep the table, so a late report would
|
||||
// surface minutes later as a fresh advertisement.
|
||||
if (this->scan_running_ && this->scan_active_ && report.scannable) {
|
||||
this->stash_adv_(report);
|
||||
return;
|
||||
}
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
|
||||
}
|
||||
|
||||
// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its
|
||||
// scan response.
|
||||
void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) {
|
||||
// One pass: find a same-device entry (deliver + reuse) while remembering the
|
||||
// first free slot as the fallback.
|
||||
PendingAdv *slot = nullptr;
|
||||
PendingAdv *free_slot = nullptr;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (!p.used) {
|
||||
if (free_slot == nullptr)
|
||||
free_slot = &p;
|
||||
continue;
|
||||
}
|
||||
if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
|
||||
// Same device advertised again before its scan response arrived — deliver
|
||||
// the previous advertisement (its scan response is not coming) and reuse
|
||||
// the slot, so no frame is ever lost.
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
slot = &p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = free_slot;
|
||||
if (slot == nullptr) {
|
||||
// Table full — degrade gracefully: deliver the advertisement unmerged.
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
slot->used = true;
|
||||
this->pending_count_++;
|
||||
memcpy(slot->mac, report.mac, 6);
|
||||
slot->addr_type = report.addr_type;
|
||||
slot->rssi = report.rssi;
|
||||
slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data);
|
||||
memcpy(slot->data, report.data, slot->data_len);
|
||||
slot->stored_ms = millis();
|
||||
}
|
||||
|
||||
// Scan response arrived: merge it with the pending advertisement from the same
|
||||
// device into ONE frame (ESP-IDF/Bluedroid semantics).
|
||||
void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) {
|
||||
// Fast-out on the empty table (loop()/flush use the same guard); this is
|
||||
// the hottest caller.
|
||||
if (this->pending_count_ != 0) {
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
|
||||
// Append in place: the slot is released on delivery, so its 62-byte
|
||||
// buffer (legacy adv + scan response) holds the merged frame directly.
|
||||
const uint8_t room = sizeof(p.data) - p.data_len;
|
||||
const uint8_t add = (report.data_len <= room) ? report.data_len : room;
|
||||
memcpy(p.data + p.data_len, report.data, add);
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
// The advertisement's RSSI, not the scan response's: every unmerged path
|
||||
// reports the advertisement's measurement, so a device's RSSI must not
|
||||
// jump between two measurements depending on merge timing.
|
||||
this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unmatched scan-response: goes out on the raw callback only (HA merges per
|
||||
// address); local listeners/triggers receive each advertisement exactly once
|
||||
// via the merged/plain path above.
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true);
|
||||
}
|
||||
|
||||
void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint8_t data_len, bool raw_only) {
|
||||
// Raw callback (the raw-advertisement path). Both full advertisements and
|
||||
// unmatched scan responses (raw_only) are forwarded.
|
||||
if (this->raw_advertisement_callback_.is_set()) {
|
||||
const ble_device_base::RawAdvertisement adv{
|
||||
.mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type};
|
||||
this->raw_advertisement_callback_.invoke(adv);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Scan-response-only frames are never parsed for local sensors/triggers.
|
||||
if (raw_only)
|
||||
return;
|
||||
ble_device_base::ESPBTDevice device;
|
||||
device.from_scan_result(mac, rssi, addr_type, data, data_len);
|
||||
// The listener list holds sensors AND this tracker's automation triggers
|
||||
// (the triggers are listeners, exactly like esp32_ble_tracker), so one
|
||||
// loop feeds both and ORs into `found`.
|
||||
bool found = false;
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->parse_device(device)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
|
||||
// it and the scan is one-shot (continuous scans would spam).
|
||||
if (!found && !this->scan_continuous_)
|
||||
this->discovered_log_.log_device(TAG, device);
|
||||
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public scan actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLETracker::start_scan() {
|
||||
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
|
||||
// set_scan_continuous() first, then calls start_scan() to begin scanning.
|
||||
if (!this->parent_->is_ready()) {
|
||||
// An on_boot automation (priority 600) runs before the controller's
|
||||
// setup() has resolved the BLE MAC; scan_start() now would rw_init() the
|
||||
// all-zero address and bring BLE up before WiFi. Latch; loop() applies
|
||||
// the start once every setup() has run.
|
||||
this->pending_start_ = true;
|
||||
return;
|
||||
}
|
||||
if (!this->scan_running_) {
|
||||
this->start_scan_();
|
||||
}
|
||||
}
|
||||
|
||||
void LN882HBLETracker::restart_scan_duration() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
// Re-anchor only the one-shot duration clock. scan_period_start_ (the
|
||||
// continuous-mode on_scan_end period) is deliberately left alone: a
|
||||
// start_scan action fired more often than scan_duration_ would otherwise
|
||||
// suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
|
||||
// publish) rides on that period.
|
||||
this->scan_start_time_ = millis();
|
||||
}
|
||||
|
||||
void LN882HBLETracker::stop_scan() {
|
||||
// Cancel a start latched before the controller's setup(); without this an
|
||||
// on_boot start_scan/stop_scan pair would still start at the first loop().
|
||||
this->pending_start_ = false;
|
||||
this->scan_continuous_ = false;
|
||||
this->stop_scan_();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal scan start / stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLETracker::start_scan_() {
|
||||
if (this->scan_running_)
|
||||
return;
|
||||
|
||||
// The controller enables the stack on first use and owns the report queue;
|
||||
// this call is all the SDK interaction the tracker ever needs.
|
||||
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
|
||||
const uint32_t now = millis();
|
||||
this->scan_running_ = true;
|
||||
this->scan_start_time_ = now;
|
||||
this->enable_loop(); // an idle non-continuous tracker disabled it in stop_scan_()
|
||||
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
|
||||
// in non-continuous mode each period is an explicit start, so asymmetric logging
|
||||
// would read as the scanner failing to come back up.
|
||||
ESP_LOGD(TAG, "BLE scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? "active" : "passive",
|
||||
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS);
|
||||
// Re-anchor the on_scan_end period to every successful start, so a restart
|
||||
// later than scan_duration (e.g. a failed OTA restoring continuous mode)
|
||||
// does not fire on_scan_end before an advertisement can arrive.
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
|
||||
void LN882HBLETracker::stop_scan_() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
this->parent_->scan_stop();
|
||||
this->scan_running_ = false;
|
||||
// DEBUG like start_scan_() — a per-period stop at INFO would read as the
|
||||
// scanner failing to come back up.
|
||||
ESP_LOGD(TAG, "BLE scan stopped");
|
||||
this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire
|
||||
// scan_running_ re-check: an on_scan_end automation runs synchronously inside
|
||||
// end_scan_period_() and may have called start_scan() — parking the loop then
|
||||
// would leave the radio scanning with no period timing or pending-adv sweep.
|
||||
if (!this->scan_continuous_ && !this->scan_running_) {
|
||||
// Nothing left to time; start_scan_() re-enables the loop.
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
// Close a scan period: deliver held advertisements whose scan response never
|
||||
// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock.
|
||||
void LN882HBLETracker::end_scan_period_(uint32_t now) {
|
||||
this->flush_pending_adv_();
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
|
||||
// Deliver every held advertisement now (scan period/scan is ending): unmerged
|
||||
// delivery, same as the timeout path in loop(). Main-task only.
|
||||
void LN882HBLETracker::flush_pending_adv_() {
|
||||
if (this->pending_count_ == 0)
|
||||
return;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used) {
|
||||
p.used = false;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
this->pending_count_ = 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ln882h_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,194 @@
|
||||
// BLE scanner for LN882H: implements ble_device_base::BLEHub on top of the
|
||||
// ln882h_ble controller (which owns all SDK calls and delivers scan reports on
|
||||
// the main task). Scan policy lives here: parameters, period timers with
|
||||
// per-period restart, and the adv+scan-response merge.
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/ln882h_ble/ln882h_ble.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ln882h_ble_tracker {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LN882HBLETracker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LN882HBLETracker : public Component,
|
||||
public ble_device_base::BLEHub,
|
||||
public Parented<ln882h_ble::LN882HBLE>,
|
||||
public ln882h_ble::BLEScanListener
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
// ---- ESPHome Component ----
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
|
||||
// mirrors esp32_ble_tracker.
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
// ---- YAML configuration setters ----
|
||||
void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; }
|
||||
void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; }
|
||||
void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; }
|
||||
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
|
||||
/// Set from YAML (scan_parameters.continuous); also the value
|
||||
/// configured_continuous() reports and a bare start_scan action restores.
|
||||
void set_configured_continuous(bool scan_continuous) {
|
||||
this->scan_continuous_ = scan_continuous;
|
||||
this->scan_continuous_configured_ = scan_continuous;
|
||||
}
|
||||
/// Runtime control (esp32_ble_tracker lambda parity): does not change the
|
||||
/// configured value, so configured_continuous() still reports what YAML
|
||||
/// asked for.
|
||||
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
|
||||
bool scan_continuous() const { return this->scan_continuous_; }
|
||||
bool configured_continuous() const { return this->scan_continuous_configured_; }
|
||||
/// Re-anchor the one-shot duration clock of a running scan to now — used
|
||||
/// when an action changes the scan mode without stopping the radio. The
|
||||
/// continuous-mode on_scan_end period is deliberately not touched.
|
||||
void restart_scan_duration();
|
||||
|
||||
// ---- Public scan control ----
|
||||
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
|
||||
void start_scan();
|
||||
void stop_scan();
|
||||
|
||||
// ---- ble_device_base::BLEHub contract ----
|
||||
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
this->listeners_.push_back(listener);
|
||||
#endif
|
||||
}
|
||||
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
|
||||
this->raw_advertisement_callback_ = callback;
|
||||
}
|
||||
ble_device_base::HubCapabilities get_capabilities() const override {
|
||||
// The LN882H controller supports active scanning; adv + scan response arrive
|
||||
// as separate reports and are merged by this tracker (Bluedroid semantics).
|
||||
// The SDK's GATT client is not exposed.
|
||||
// scan_mode_switch: request_scan_mode() is implemented (restart-if-running).
|
||||
return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true};
|
||||
}
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
void get_adapter_mac(uint8_t out[6]) override {
|
||||
uint8_t mac[6];
|
||||
this->parent_->get_mac_lsb_first(mac);
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = mac[5 - i];
|
||||
}
|
||||
bool scan_running() override { return this->scan_running_; }
|
||||
bool scan_active() override { return this->scan_active_; }
|
||||
bool request_scan_mode(bool active) override;
|
||||
|
||||
// ---- ln882h_ble::BLEScanListener ----
|
||||
// Delivered by the controller's loop() on the ESPHome main task — the
|
||||
// rw-task → main-task handoff already happened in the controller's queue.
|
||||
// Demultiplexes advertisements vs scan responses and drives the merge.
|
||||
void on_scan_report(const ln882h_ble::BLEScanReport &report) override;
|
||||
|
||||
protected:
|
||||
// Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into
|
||||
// one result before ESPHome sees it; the LN controller reports them separately):
|
||||
// a scannable advertisement is held here briefly, its scan response is appended
|
||||
// on arrival and the pair is delivered as ONE merged frame. Held entries whose
|
||||
// scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS.
|
||||
// All of this runs on the main task (the controller queue already crossed tasks),
|
||||
// so no locking is involved.
|
||||
void stash_adv_(const ln882h_ble::BLEScanReport &report);
|
||||
void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report);
|
||||
// Dispatch one (possibly merged) advertisement: the raw
|
||||
// callback, and — unless raw_only — parsing for listeners/triggers. raw_only
|
||||
// marks unmatched scan-response frames: forwarded on the raw callback only,
|
||||
// never to local sensors/triggers (HA merges per address).
|
||||
void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
bool raw_only);
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
// Close a scan period: flush held advertisements (unmerged) BEFORE
|
||||
// on_scan_end fires, then re-anchor the period clock to `now`.
|
||||
void end_scan_period_(uint32_t now);
|
||||
void flush_pending_adv_();
|
||||
|
||||
bool scan_running_{false};
|
||||
bool scan_active_{false};
|
||||
// Defaults are the LN882H SDK's recommended scan parameters
|
||||
// (ln_ble_scan.h: SCAN_INTERVAL_DEF 0xA0, SCAN_WINDOW_DEF 0x50 → 50 % duty).
|
||||
// uint16_t matches the controller's scan_start() parameters.
|
||||
uint16_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms (SDK SCAN_INTERVAL_DEF)
|
||||
uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %)
|
||||
uint32_t scan_duration_{300000};
|
||||
bool scan_continuous_{true};
|
||||
bool pending_start_{false}; // start_scan() latched before the controller's setup()
|
||||
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
|
||||
bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure
|
||||
#endif
|
||||
uint32_t scan_start_time_{0};
|
||||
|
||||
// Pending scannable advertisements awaiting their scan response (active scan).
|
||||
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as
|
||||
// ESP-IDF delivers on ESP32. Main-task only.
|
||||
struct PendingAdv {
|
||||
bool used{false};
|
||||
uint8_t mac[6];
|
||||
uint8_t addr_type;
|
||||
int8_t rssi;
|
||||
uint8_t data_len; // <= sizeof(data)
|
||||
uint8_t data[62];
|
||||
uint32_t stored_ms;
|
||||
};
|
||||
// Sized for the unanswered case: a pair that IS answered normally matches
|
||||
// within one queue drain, so a slot is held for the full timeout only by
|
||||
// scannable devices that never reply. 8 concurrent such advertisers before
|
||||
// the merge degrades (frames still delivered, just unmerged) at ~80 B each.
|
||||
static constexpr size_t MAX_PENDING_ADV = 8;
|
||||
// On air a scan response follows its advertisement by T_IFS (150 µs) — the
|
||||
// timeout only covers HOST-side report queuing in rw_task under WiFi/BLE
|
||||
// coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin,
|
||||
// while staying below any device's re-advertising period.
|
||||
static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300;
|
||||
PendingAdv pending_adv_[MAX_PENDING_ADV];
|
||||
// Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table
|
||||
// in the common case (empty: passive scan, or every pair already matched).
|
||||
uint8_t pending_count_{0};
|
||||
|
||||
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
|
||||
|
||||
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Parsed-advertisement consumers registered through ble_device_base.
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiations.
|
||||
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
|
||||
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
|
||||
// in ble_device_base, identical output on every tracker backend. Guarded like
|
||||
// its only writer so a no-listener build does not carry an unused vector.
|
||||
ble_device_base::DiscoveredDeviceLog discovered_log_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ln882h_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -51,7 +51,11 @@ def _set_core_data(config):
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA
|
||||
# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared
|
||||
# by every LibreTiny platform, and prepending this platform's _set_core_data
|
||||
# onto the shared object would run it for every platform's validation once two
|
||||
# platform modules are imported in one process (device-builder, tests).
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({})
|
||||
|
||||
PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA
|
||||
|
||||
|
||||
@@ -410,10 +410,16 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
from esphome.components.esp8266.const import enable_serial, enable_serial1
|
||||
|
||||
hw_uart = config.get(CONF_HARDWARE_UART, UART0)
|
||||
if has_serial_logging and hw_uart in (UART0, UART0_SWAP):
|
||||
if not has_serial_logging:
|
||||
# No serial logging: stub out ROM ets_putc so stray output (newlib
|
||||
# stdout, lwIP diagnostics) cannot block on a slow or shared UART0.
|
||||
# ets_putc always writes to the physical UART and cannot be disabled
|
||||
# through uart_set_debug(); see __wrap_ets_putc in logger_esp8266.cpp.
|
||||
cg.add_build_flag("-Wl,--wrap=ets_putc")
|
||||
elif hw_uart in (UART0, UART0_SWAP):
|
||||
cg.add_define("USE_ESP8266_LOGGER_SERIAL")
|
||||
enable_serial()
|
||||
elif has_serial_logging and hw_uart == UART1:
|
||||
elif hw_uart == UART1:
|
||||
cg.add_define("USE_ESP8266_LOGGER_SERIAL1")
|
||||
enable_serial1()
|
||||
|
||||
|
||||
@@ -49,4 +49,17 @@ const LogString *Logger::get_uart_selection_() {
|
||||
}
|
||||
|
||||
} // namespace esphome::logger
|
||||
|
||||
#if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1)
|
||||
// With serial logging disabled, ROM ets_putc still writes to the physical UART0
|
||||
// at whatever baud rate a uart bus configured there; uart_set_debug(UART_NO)
|
||||
// only silences the installable putc1 hook, not ets_putc itself. Blocking
|
||||
// writes at a low baud rate (for example 4800 for a power monitoring chip) can
|
||||
// starve the soft watchdog. All linked callers (newlib stdout, lwIP
|
||||
// diagnostics, postmortem dumps) are redirected here by -Wl,--wrap=ets_putc.
|
||||
// IRAM_ATTR because the ROM original is callable with the flash cache
|
||||
// disabled (for example from newlib's _write_r, which is placed in IRAM).
|
||||
extern "C" void IRAM_ATTR __wrap_ets_putc(char) {}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
@@ -21,12 +21,22 @@ class LvAnimationTiming {
|
||||
|
||||
class LvAnimationTimingRoundTrip : public LvAnimationTiming {
|
||||
public:
|
||||
// moving_length_ is the fraction of progress spent moving in each direction, in (0, 0.5].
|
||||
// Callers must pass pause in [0, 1) -- pause == 1.0 would make moving_length_ zero and divide by zero below.
|
||||
LvAnimationTimingRoundTrip(float pause) : moving_length_((1.0f - pause) / 2.0f) {}
|
||||
float map_progress(float value) override {
|
||||
value *= 2.0f;
|
||||
if (value > 1.0f)
|
||||
return 2.0f - value;
|
||||
return value;
|
||||
if (value < this->moving_length_) {
|
||||
return value / this->moving_length_;
|
||||
}
|
||||
if (value > 1.0f - this->moving_length_) {
|
||||
return (1.0f - value) / this->moving_length_;
|
||||
}
|
||||
// pause in the middle
|
||||
return 1.0f;
|
||||
}
|
||||
|
||||
protected:
|
||||
float moving_length_{};
|
||||
};
|
||||
|
||||
class LvAnimationTimingGravity : public LvAnimationTiming {
|
||||
|
||||
@@ -42,6 +42,7 @@ LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip")
|
||||
LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut")
|
||||
|
||||
CONF_BOUNCE = "bounce"
|
||||
CONF_PAUSE = "pause"
|
||||
|
||||
|
||||
def timing_class(name, extras=None):
|
||||
@@ -60,10 +61,20 @@ TIMING_SCHEMA = cv.maybe_simple_value(
|
||||
cv.typed_schema(
|
||||
dict(
|
||||
[
|
||||
timing_class("round_trip"),
|
||||
timing_class(
|
||||
"round_trip",
|
||||
{
|
||||
cv.Optional(CONF_PAUSE, default=0.0): cv.All(
|
||||
cv.percentage,
|
||||
cv.float_range(
|
||||
min=0.0, max=1.0, min_included=True, max_included=False
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
timing_class(
|
||||
"ease_in_out",
|
||||
{cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float},
|
||||
{cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float},
|
||||
),
|
||||
timing_class(
|
||||
"gravity",
|
||||
|
||||
@@ -5,7 +5,14 @@ from esphome import automation
|
||||
from esphome.automation import StatelessLambdaAction
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT
|
||||
from esphome.const import (
|
||||
CONF_ACTION,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
CONF_POSITION,
|
||||
CONF_ROTATION,
|
||||
CONF_TIMEOUT,
|
||||
)
|
||||
from esphome.core import Lambda
|
||||
from esphome.cpp_generator import TemplateArguments, get_variable
|
||||
from esphome.cpp_types import nullptr
|
||||
@@ -28,6 +35,7 @@ from .defines import (
|
||||
get_focused_widgets,
|
||||
get_options,
|
||||
get_refreshed_widgets,
|
||||
literal,
|
||||
)
|
||||
from .layout import layout_validator
|
||||
from .lv_validation import lv_bool, lv_milliseconds, lv_rotation
|
||||
@@ -36,6 +44,7 @@ from .lvcode import (
|
||||
UPDATE_EVENT,
|
||||
LambdaContext,
|
||||
LocalVariable,
|
||||
LvConditional,
|
||||
LvglComponent,
|
||||
ReturnStatement,
|
||||
add_line_marks,
|
||||
@@ -376,6 +385,48 @@ async def obj_show_to_code(config, action_id, template_arg, args):
|
||||
return await action_to_code(widgets, do_show, action_id, template_arg, args)
|
||||
|
||||
|
||||
SET_Z_INDEX_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.ensure_list(
|
||||
cv.maybe_simple_value(
|
||||
{cv.Required(CONF_ID): cv.use_id(lv_obj_t)},
|
||||
key=CONF_ID,
|
||||
)
|
||||
),
|
||||
cv.Required(CONF_POSITION): cv.Any(
|
||||
cv.one_of("TOP", "BOTTOM", "UP", "DOWN", upper=True), cv.int_
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.widget.set_z_index", ObjUpdateAction, SET_Z_INDEX_SCHEMA, synchronous=True
|
||||
)
|
||||
async def obj_set_z_index_to_code(config, action_id, template_arg, args):
|
||||
position = config[CONF_POSITION]
|
||||
|
||||
async def do_set_z_index(widget: Widget):
|
||||
if position == "TOP":
|
||||
lv_obj.move_foreground(widget.obj)
|
||||
elif position == "BOTTOM":
|
||||
lv_obj.move_background(widget.obj)
|
||||
elif position == "UP":
|
||||
lv_obj.move_to_index(
|
||||
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1")
|
||||
)
|
||||
elif position == "DOWN":
|
||||
with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"):
|
||||
lv_obj.move_to_index(
|
||||
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1")
|
||||
)
|
||||
else:
|
||||
lv_obj.move_to_index(widget.obj, position)
|
||||
|
||||
widgets = [widget.outer or widget for widget in await get_widgets(config[CONF_ID])]
|
||||
return await action_to_code(widgets, do_set_z_index, action_id, template_arg, args)
|
||||
|
||||
|
||||
def focused_id(value):
|
||||
value = cv.use_id(lv_pseudo_button_t)(value)
|
||||
get_focused_widgets().add(value)
|
||||
|
||||
@@ -21,7 +21,18 @@ void Mcp4461Component::setup() {
|
||||
auto init_val = this->reg_[i].initial_value;
|
||||
if (init_val.has_value()) {
|
||||
uint16_t initial_state = static_cast<uint16_t>(*init_val * 256.0f);
|
||||
this->write_wiper_level_(i, initial_state);
|
||||
if (i > 3) {
|
||||
// NV wiper: an unconditional write would cost one EEPROM erase/write cycle on EVERY
|
||||
// boot. Only write when the stored value actually differs — and always write when
|
||||
// the read itself failed (a failed read returns 0, which would silently skip the
|
||||
// write whenever initial_value is 0).
|
||||
bool read_ok = false;
|
||||
if (this->read_wiper_level_(i, &read_ok) != initial_state || !read_ok) {
|
||||
this->write_wiper_level_(i, initial_state);
|
||||
}
|
||||
} else {
|
||||
this->write_wiper_level_(i, initial_state);
|
||||
}
|
||||
}
|
||||
if (this->reg_[i].enabled) {
|
||||
this->reg_[i].state = this->read_wiper_level_(i);
|
||||
@@ -34,6 +45,23 @@ void Mcp4461Component::setup() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push the YAML terminal configuration to the TCON registers. TCON is volatile — on POR
|
||||
// the chip restores wiper levels from the NV registers but resets TCON to "all terminals
|
||||
// connected", so any terminal_a/b/w disables from the config MUST be written here.
|
||||
for (uint8_t t = 0; t < 2; t++) {
|
||||
Mcp4461TerminalIdx terminal_connector = static_cast<Mcp4461TerminalIdx>(t);
|
||||
uint8_t terminal_byte = this->calc_terminal_connector_byte_(terminal_connector);
|
||||
this->set_terminal_register_(terminal_connector, terminal_byte);
|
||||
}
|
||||
}
|
||||
|
||||
void Mcp4461Component::set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms) {
|
||||
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
|
||||
if (wiper_idx > 3) {
|
||||
return; // NV channels E-H are the persistence target themselves
|
||||
}
|
||||
this->reg_[wiper_idx].nonvolatile = true;
|
||||
this->reg_[wiper_idx].nonvolatile_write_delay_ms = write_delay_ms;
|
||||
}
|
||||
|
||||
void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) {
|
||||
@@ -77,9 +105,12 @@ void Mcp4461Component::dump_config() {
|
||||
// so also invalid for nonvolatile. For these, only print current level.
|
||||
// reworked to be a one-line intentionally, as output would not be in order
|
||||
if (i < 4) {
|
||||
ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i,
|
||||
this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw),
|
||||
ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w));
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, "
|
||||
"A: %s, B: %s, W: %s, NV: %s",
|
||||
i, this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw),
|
||||
ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w),
|
||||
ONOFF(this->reg_[i].nonvolatile));
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state);
|
||||
}
|
||||
@@ -92,8 +123,10 @@ void Mcp4461Component::loop() {
|
||||
}
|
||||
for (uint8_t i = 0; i < 8; i++) {
|
||||
if (this->reg_[i].update_level) {
|
||||
// set wiper i state if changed
|
||||
if (this->reg_[i].state != this->read_wiper_level_(i)) {
|
||||
// set wiper i state if changed — a failed read (returns 0) must not suppress the
|
||||
// write when the target state is 0, same hardening as the NV read-compare paths
|
||||
bool read_ok = false;
|
||||
if (this->reg_[i].state != this->read_wiper_level_(i, &read_ok) || !read_ok) {
|
||||
this->write_wiper_level_(i, this->reg_[i].state);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +145,67 @@ void Mcp4461Component::loop() {
|
||||
}
|
||||
this->reg_[i].update_terminal = false;
|
||||
}
|
||||
this->process_nonvolatile_dirty_();
|
||||
}
|
||||
|
||||
void Mcp4461Component::process_nonvolatile_dirty_() {
|
||||
const uint32_t now = millis();
|
||||
for (uint8_t i = 0; i < 4; i++) {
|
||||
if (!this->reg_[i].nonvolatile || !this->reg_[i].nonvolatile_dirty) {
|
||||
continue;
|
||||
}
|
||||
if ((now - this->reg_[i].last_level_change_ms) < this->reg_[i].nonvolatile_write_delay_ms) {
|
||||
continue; // still settling — debounce window not over yet
|
||||
}
|
||||
// Never block the loop on a still-running EEPROM cycle (t_WC up to 10 ms); datasheet:
|
||||
// during an EEPROM write only volatile commands are accepted. Retry on the next loop.
|
||||
if (this->is_writing_()) {
|
||||
continue;
|
||||
}
|
||||
// Clear the dirty flag on success — and equally when WP or WiperLock block the write
|
||||
// permanently, instead of retrying forever.
|
||||
if (this->store_level_nonvolatile_(static_cast<Mcp4461WiperIdx>(i)) || this->write_protected_ ||
|
||||
this->reg_[i].wiper_lock_active) {
|
||||
this->reg_[i].nonvolatile_dirty = false;
|
||||
} else {
|
||||
// Transient failure (e.g. I2C error): without this, the retry fires on every single
|
||||
// loop() iteration, spamming a warning each time. Re-arming the timestamp reuses the
|
||||
// stability delay as a natural retry backoff.
|
||||
this->reg_[i].last_level_change_ms = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Mcp4461Component::store_level_nonvolatile_(Mcp4461WiperIdx wiper) {
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_)));
|
||||
return false;
|
||||
}
|
||||
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
|
||||
if (wiper_idx > 3) {
|
||||
// E-H ARE the nonvolatile registers — keep this consistent with the other guards
|
||||
// instead of failing silently (reachable via the store_nonvolatile action).
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
|
||||
return false;
|
||||
}
|
||||
if (this->reg_[wiper_idx].wiper_lock_active) {
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
|
||||
return false;
|
||||
}
|
||||
const uint16_t level = this->reg_[wiper_idx].state;
|
||||
// Skip the EEPROM cycle entirely when the NV register already holds the value. A failed
|
||||
// read must NOT count as a match (it returns 0): fall through to the write instead — if
|
||||
// the bus is really down, the write fails too and the dirty flag stays set for a retry.
|
||||
bool read_ok = false;
|
||||
if (this->read_wiper_level_(wiper_idx + 4, &read_ok) == level && read_ok) {
|
||||
return true;
|
||||
}
|
||||
ESP_LOGV(TAG, "Persisting wiper %u level %u to nonvolatile register", wiper_idx, level);
|
||||
if (!this->mcp4461_write_(this->get_wiper_address_(wiper_idx + 4), level, true)) {
|
||||
ESP_LOGW(TAG, "Error persisting wiper %u level %u", wiper_idx, level);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t Mcp4461Component::get_status_register_() {
|
||||
@@ -210,7 +304,10 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) {
|
||||
return this->read_wiper_level_(wiper_idx);
|
||||
}
|
||||
|
||||
uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) {
|
||||
uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx, bool *ok) {
|
||||
if (ok != nullptr) {
|
||||
*ok = false;
|
||||
}
|
||||
uint8_t addr = this->get_wiper_address_(wiper_idx);
|
||||
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::READ);
|
||||
if (wiper_idx > 3) {
|
||||
@@ -225,6 +322,9 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) {
|
||||
ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx);
|
||||
return 0;
|
||||
}
|
||||
if (ok != nullptr) {
|
||||
*ok = true;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -265,6 +365,10 @@ bool Mcp4461Component::set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value) {
|
||||
ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value);
|
||||
this->reg_[wiper_idx].state = value;
|
||||
this->reg_[wiper_idx].update_level = true;
|
||||
if (this->reg_[wiper_idx].nonvolatile) {
|
||||
this->reg_[wiper_idx].nonvolatile_dirty = true;
|
||||
this->reg_[wiper_idx].last_level_change_ms = millis();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -335,6 +439,12 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
|
||||
return false;
|
||||
}
|
||||
if (wiper_idx > 3) {
|
||||
// Datasheet: increment commands are only valid for the volatile wiper registers —
|
||||
// the chip NACKs them on nonvolatile addresses.
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
|
||||
return false;
|
||||
}
|
||||
if (this->reg_[wiper_idx].state == 256) {
|
||||
ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx);
|
||||
return false;
|
||||
@@ -349,6 +459,10 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
|
||||
return false;
|
||||
}
|
||||
this->reg_[wiper_idx].state++;
|
||||
if (this->reg_[wiper_idx].nonvolatile) {
|
||||
this->reg_[wiper_idx].nonvolatile_dirty = true;
|
||||
this->reg_[wiper_idx].last_level_change_ms = millis();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -366,6 +480,12 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
|
||||
return false;
|
||||
}
|
||||
if (wiper_idx > 3) {
|
||||
// Datasheet: decrement commands are only valid for the volatile wiper registers —
|
||||
// the chip NACKs them on nonvolatile addresses.
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
|
||||
return false;
|
||||
}
|
||||
if (this->reg_[wiper_idx].state == 0) {
|
||||
ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx);
|
||||
return false;
|
||||
@@ -380,11 +500,18 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
|
||||
return false;
|
||||
}
|
||||
this->reg_[wiper_idx].state--;
|
||||
if (this->reg_[wiper_idx].nonvolatile) {
|
||||
this->reg_[wiper_idx].nonvolatile_dirty = true;
|
||||
this->reg_[wiper_idx].last_level_change_ms = millis();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) {
|
||||
uint8_t i = static_cast<uint8_t>(terminal_connector) <= 1 ? 0 : 2;
|
||||
// TCON0 covers wipers 0/1 (A/B), TCON1 covers wipers 2/3 (C/D). The enum only holds
|
||||
// 0 and 1, so the old `<= 1 ? 0 : 2` collapsed to always-0 and built TCON1 from
|
||||
// channels A/B's flags — mirror the (correct) read path in update_terminal_register_().
|
||||
uint8_t i = static_cast<uint8_t>(terminal_connector) == 0 ? 0 : 2;
|
||||
uint8_t new_value_byte = 0;
|
||||
new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_b);
|
||||
new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_w) << 1;
|
||||
@@ -471,6 +598,12 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
|
||||
return;
|
||||
}
|
||||
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
|
||||
if (wiper_idx > 3) {
|
||||
// Terminal control only exists for the volatile wipers; loop() would otherwise emit
|
||||
// an unrelated TCON write and silently drop the request.
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx);
|
||||
switch (terminal) {
|
||||
case 'h':
|
||||
@@ -498,6 +631,10 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
|
||||
return;
|
||||
}
|
||||
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
|
||||
if (wiper_idx > 3) {
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx);
|
||||
switch (terminal) {
|
||||
case 'h':
|
||||
|
||||
@@ -17,6 +17,16 @@ struct WiperState {
|
||||
bool wiper_lock_active = false;
|
||||
bool update_level = false;
|
||||
bool update_terminal = false;
|
||||
// Nonvolatile persistence (volatile wipers 0-3 only): when enabled, every level change is
|
||||
// mirrored into the chip's NV wiper register after nonvolatile_write_delay of stability, so
|
||||
// the chip restores it on power-on. The delay both debounces bursts (e.g. light transitions
|
||||
// writing dozens of levels per second) and protects the EEPROM's limited endurance —
|
||||
// without it, every intermediate step would cost one of the ~1M erase/write cycles and
|
||||
// stall the bus for up to t_WC (10 ms) each.
|
||||
bool nonvolatile = false;
|
||||
uint32_t nonvolatile_write_delay_ms = 1000;
|
||||
bool nonvolatile_dirty = false;
|
||||
uint32_t last_level_change_ms = 0;
|
||||
};
|
||||
|
||||
// default wiper state is 128 / 0x80h
|
||||
@@ -86,6 +96,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
|
||||
/// @param[in] wiper - the wiper to set the value for
|
||||
/// @param[in] initial_value - the initial value in range 0-1.0 as float
|
||||
void set_initial_value(Mcp4461WiperIdx wiper, float initial_value);
|
||||
/// @brief enable nonvolatile persistence for a volatile wiper (0-3): every level change is
|
||||
/// mirrored to the corresponding NV wiper register after the given stability delay
|
||||
/// @param[in] wiper - the (volatile) wiper to persist
|
||||
/// @param[in] write_delay_ms - stability delay before the NV write (debounce / EEPROM wear)
|
||||
void set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms);
|
||||
/// @brief public function used to set disable terminal config
|
||||
/// @param[in] wiper - the wiper to set the value for
|
||||
/// @param[in] terminal - the terminal to disable, one of ['a','b','w','h']
|
||||
@@ -98,7 +113,10 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
|
||||
bool read_16_(uint8_t address, uint16_t *buf);
|
||||
void update_write_protection_status_();
|
||||
uint8_t get_wiper_address_(uint8_t wiper);
|
||||
uint16_t read_wiper_level_(uint8_t wiper);
|
||||
/// Read a wiper register. On I2C failure returns 0 — callers that must distinguish
|
||||
/// a real 0 from a failed read pass `ok` (added for the NV read-compare paths, where
|
||||
/// acting on a failed read would skip a required write or drop a pending persist).
|
||||
uint16_t read_wiper_level_(uint8_t wiper, bool *ok = nullptr);
|
||||
uint8_t get_status_register_();
|
||||
uint16_t get_wiper_level_(Mcp4461WiperIdx wiper);
|
||||
bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value);
|
||||
@@ -110,6 +128,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
|
||||
void enable_terminal_(Mcp4461WiperIdx wiper, char terminal);
|
||||
void disable_terminal_(Mcp4461WiperIdx, char terminal);
|
||||
bool is_writing_();
|
||||
/// Copy the current volatile level of wiper 0-3 into its NV register (immediate, blocking
|
||||
/// only for a pending previous EEPROM cycle). Returns false while WP is active or on error.
|
||||
bool store_level_nonvolatile_(Mcp4461WiperIdx wiper);
|
||||
/// Deferred NV mirroring driven from loop() — see WiperState::nonvolatile.
|
||||
void process_nonvolatile_dirty_();
|
||||
bool is_eeprom_ready_for_writing_(bool wait_if_not_ready);
|
||||
void write_wiper_level_(uint8_t wiper, uint16_t value);
|
||||
bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false);
|
||||
@@ -139,6 +162,9 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
|
||||
return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited.");
|
||||
case MCP4461_STATUS_OK:
|
||||
return LOG_STR("Status OK");
|
||||
case MCP4461_PROHIBITED_FOR_NONVOLATILE:
|
||||
return LOG_STR(
|
||||
"Increment/decrement, store, and terminal control are prohibited on the nonvolatile wipers (E-H).");
|
||||
default:
|
||||
return LOG_STR("Unknown");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
@@ -26,6 +27,43 @@ CHANNEL_OPTIONS = {
|
||||
CONF_TERMINAL_A = "terminal_a"
|
||||
CONF_TERMINAL_B = "terminal_b"
|
||||
CONF_TERMINAL_W = "terminal_w"
|
||||
CONF_NONVOLATILE = "nonvolatile"
|
||||
CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay"
|
||||
|
||||
# Volatile wiper channels that have a nonvolatile shadow register on the chip
|
||||
VOLATILE_CHANNELS = ("A", "B", "C", "D")
|
||||
|
||||
|
||||
def _validate_nonvolatile(config):
|
||||
channel = str(config[CONF_CHANNEL])
|
||||
|
||||
# Channels E-H address the nonvolatile registers directly — the mirroring options only
|
||||
# make sense for the volatile channels A-D.
|
||||
if channel not in VOLATILE_CHANNELS:
|
||||
# Only reject what the user EXPLICITLY asked for and cannot have: enabling the
|
||||
# mirroring or tuning its delay on E-H. An explicit `nonvolatile: false` is a
|
||||
# harmless no-op and stays valid; bare configs (no key at all) must keep working.
|
||||
# NOTE: FINAL_VALIDATE_SCHEMA intentionally mutates `config` in-place (uses setdefault) to apply defaults for callers.
|
||||
if config.get(CONF_NONVOLATILE) or CONF_NONVOLATILE_WRITE_DELAY in config:
|
||||
raise cv.Invalid(
|
||||
f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the "
|
||||
f"volatile channels A-D; channels E-H are the nonvolatile registers themselves"
|
||||
)
|
||||
return config
|
||||
|
||||
config.setdefault(CONF_NONVOLATILE, True)
|
||||
if config[CONF_NONVOLATILE]:
|
||||
config.setdefault(
|
||||
CONF_NONVOLATILE_WRITE_DELAY,
|
||||
cv.positive_time_period_milliseconds("1s"),
|
||||
)
|
||||
elif CONF_NONVOLATILE_WRITE_DELAY in config:
|
||||
# Same consistency as the E-H rejection above: never silently ignore user input.
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
@@ -36,9 +74,21 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean,
|
||||
cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean,
|
||||
cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0),
|
||||
# No schema defaults here: a default would materialize the keys on EVERY channel,
|
||||
# making existing bare E-H configs fail final validation. The effective defaults
|
||||
# (nonvolatile: true, delay 1s) are applied for the volatile channels A-D inside
|
||||
# _validate_nonvolatile instead. Default-on rationale: the chip restores the
|
||||
# nonvolatile wiper levels at power-on, so persisting every settled level change is
|
||||
# the least surprising behavior — the pot simply comes back where it was. The write
|
||||
# is deferred by nonvolatile_write_delay to debounce transitions and protect the
|
||||
# EEPROM's endurance.
|
||||
cv.Optional(CONF_NONVOLATILE): cv.boolean,
|
||||
cv.Optional(CONF_NONVOLATILE_WRITE_DELAY): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_nonvolatile
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
parent = await cg.get_variable(config[CONF_MCP4461_ID])
|
||||
@@ -57,5 +107,71 @@ async def to_code(config):
|
||||
cg.add(
|
||||
parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE])
|
||||
)
|
||||
if str(config[CONF_CHANNEL]) in VOLATILE_CHANNELS and config[CONF_NONVOLATILE]:
|
||||
cg.add(
|
||||
parent.set_nonvolatile(
|
||||
config[CONF_CHANNEL],
|
||||
config[CONF_NONVOLATILE_WRITE_DELAY],
|
||||
)
|
||||
)
|
||||
await output.register_output(var, config)
|
||||
await cg.register_parented(var, config[CONF_MCP4461_ID])
|
||||
|
||||
|
||||
# ---- 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)}
|
||||
)
|
||||
|
||||
CONF_TERMINAL = "terminal"
|
||||
CONF_ENABLE = "enable"
|
||||
|
||||
TERMINAL_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper),
|
||||
cv.Required(CONF_TERMINAL): cv.one_of("a", "b", "w", "h", lower=True),
|
||||
cv.Required(CONF_ENABLE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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, action_id, template_arg, args):
|
||||
wiper = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, wiper)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"mcp4461.wiper.store_nonvolatile",
|
||||
WiperStoreNonvolatileAction,
|
||||
WIPER_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args):
|
||||
wiper = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, wiper)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"mcp4461.wiper.set_terminal",
|
||||
WiperSetTerminalAction,
|
||||
TERMINAL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args):
|
||||
wiper = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(
|
||||
action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "mcp4461_output.h"
|
||||
|
||||
namespace esphome::mcp4461 {
|
||||
|
||||
template<typename... Ts> class WiperIncreaseAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {}
|
||||
void play(Ts... x) override { this->wiper_->increase_wiper(); }
|
||||
|
||||
protected:
|
||||
Mcp4461Wiper *wiper_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class WiperDecreaseAction : public Action<Ts...> {
|
||||
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<typename... Ts> class WiperStoreNonvolatileAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {}
|
||||
void play(Ts... x) override { this->wiper_->store_nonvolatile(); }
|
||||
|
||||
protected:
|
||||
Mcp4461Wiper *wiper_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class WiperSetTerminalAction : public Action<Ts...> {
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user