Compare commits

..
Author SHA1 Message Date
J. Nick Koston bcea8ceb8e Merge branch 'gatt-consumer-support' into radon-eye-neutral-gatt 2026-08-09 01:01:27 -05:00
J. Nick Koston e67cd5b978 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 01:01:25 -05:00
J. Nick Koston 06a52f682f Name the platform explicitly in schema builders for the dumper 2026-08-09 01:01:21 -05:00
J. Nick Koston 0fe4d608ce Merge branch 'gatt-consumer-support' into radon-eye-neutral-gatt 2026-08-09 00:59:20 -05:00
J. Nick Koston 7e89d962f4 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 00:59:18 -05:00
J. Nick Koston 409ff027cc Collapse the identical ignore branches 2026-08-09 00:59:16 -05:00
J. Nick Koston cbe6c83e8d Consume the GattClientListener interface 2026-08-09 00:48:47 -05:00
J. Nick Koston dab3f370a0 Merge branch 'gatt-consumer-support' into radon-eye-neutral-gatt 2026-08-09 00:46:55 -05:00
J. Nick Koston 4d90dbda97 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 00:45:42 -05:00
J. Nick Koston 3c7e5000a5 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 00:44:41 -05:00
J. Nick Koston 7af211ab54 Deliver completions through a GattClientListener interface 2026-08-09 00:44:35 -05:00
J. Nick Koston d8e90d3ecc Merge branch 'gatt-consumer-support' into radon-eye-neutral-gatt 2026-08-09 00:35:58 -05:00
J. Nick Koston 5420b0106a Use the CORE platform property 2026-08-09 00:35:54 -05:00
J. Nick Koston ce082664c9 Merge remote-tracking branch 'origin/radon-eye-neutral-gatt' into radon-eye-neutral-gatt 2026-08-09 00:31:02 -05:00
J. Nick Koston 1a30917bb7 Uppercase the function-local static constant for clang-tidy 2026-08-09 00:29:25 -05:00
J. Nick Koston 792441a046 Merge branch 'gatt-consumer-support' into radon-eye-neutral-gatt
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
2026-08-09 00:29:25 -05:00
J. Nick Koston 964835c523 Merge branch 'esp32-proxy-flip' into gatt-consumer-support
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
2026-08-09 00:29:06 -05:00
J. Nick Koston cc81069d02 Keep the esp32 maintenance functions outside the gated surface 2026-08-09 00:28:54 -05:00
J. Nick Koston 20f63026a2 Dedicated-backend consumer support: proxy gate, consumer schema, slot ledger 2026-08-09 00:27:53 -05:00
J. Nick Koston ba567cecc3 Shared backend codegen registry and flip cleanups 2026-08-09 00:23:34 -05:00
J. Nick Koston ee7fb83334 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
2026-08-09 00:20:09 -05:00
J. Nick Koston 9db31db24d Multi-consumer sink, table materializer, and review fixes for the backend 2026-08-09 00:19:39 -05:00
J. Nick Koston 2724ec3c30 Combined-stack simplify: loop discipline, gated materializer, registry, peer keys 2026-08-09 00:16:15 -05:00
pre-commit-ci-lite[bot] 30aac5b01c apply automatic formatting fixes 2026-08-09 04:55:49 +00:00
J. Nick Koston ac6d2fe322 Merge branch 'esp32-proxy-flip' into radon-eye-neutral-gatt 2026-08-08 23:53:52 -05:00
J. Nick Koston ceca66c01a Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:53:50 -05:00
J. Nick Koston dc97c5a161 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:53:48 -05:00
J. Nick Koston 97bbcf55d9 Describe the ladder arm's real safety net in the test docstring 2026-08-08 23:53:45 -05:00
J. Nick Koston ef0d2ed139 Rename the proxy-surface predicate in the proxy TUs 2026-08-08 23:53:17 -05:00
J. Nick Koston 8a88120143 Simplify: flash vtable sink, shared table walker, one proxy predicate, slot ledger 2026-08-08 23:53:07 -05:00
J. Nick Koston 49191c1ed8 Merge branch 'esp32-proxy-flip' into radon-eye-neutral-gatt 2026-08-08 23:27:42 -05:00
J. Nick Koston d135ac97e5 Report teardown once at CLOSE_EVT and refuse connects on a busy slot 2026-08-08 23:27:04 -05:00
J. Nick Koston 296dd19361 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:21:58 -05:00
J. Nick Koston 138304cfbe Fail discovery on count errors and free idle slots at once 2026-08-08 23:21:57 -05:00
J. Nick Koston 2e8700f090 Own the platform dispatch in bluetooth_connection and gate the proxy surface 2026-08-08 23:16:58 -05:00
J. Nick Koston 5d42a69d7a Merge branch 'esp32-proxy-flip' into radon-eye-neutral-gatt 2026-08-08 23:09:19 -05:00
J. Nick Koston 9fef1862be Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:09:18 -05:00
J. Nick Koston 80758f11aa Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:09:16 -05:00
J. Nick Koston 9d8362738f Use the canonical board id and the sibling test conventions 2026-08-08 23:09:13 -05:00
J. Nick Koston a21d71ddbe WIP: radon_eye_rd200 onto the neutral GATT contract 2026-08-08 23:08:32 -05:00
J. Nick Koston b2c22b5802 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:45:31 -05:00
J. Nick Koston f62009ecc9 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:45:29 -05:00
J. Nick Koston c09e3e0d45 Check the alias defines through real codegen 2026-08-08 22:42:56 -05:00
J. Nick Koston cdd7bef74a Check the alias defines through real codegen 2026-08-08 22:40:36 -05:00
J. Nick Koston 0e1321a813 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:38:31 -05:00
J. Nick Koston 022b612366 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:38:29 -05:00
J. Nick Koston 2dc9fbec5e Pin the tracker alias three-place invariant 2026-08-08 22:38:26 -05:00
J. Nick Koston 9dafff6a61 Apply the simplify findings and fix the passive esp32 build 2026-08-08 22:32:36 -05:00
J. Nick Koston 3c7a1062c8 Flip the esp32 proxy onto the hub wrapper 2026-08-08 22:21:38 -05:00
J. Nick Koston 8164cdd018 Stream in place through a backend cut-through 2026-08-08 22:08:16 -05:00
J. Nick Koston 4ea491d46f Bound the streaming peak to one batch window 2026-08-08 21:59:08 -05:00
J. Nick Koston 7fdb6cd2d5 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 21:41:25 -05:00
J. Nick Koston 2d50e07e90 Fold the push-hub surface into the concept 2026-08-08 21:41:23 -05:00
J. Nick Koston 78a451a1c0 Apply the simplify findings 2026-08-08 21:39:48 -05:00
J. Nick Koston 2483621500 Bluedroid backend for the GATT client contract 2026-08-08 21:31:09 -05:00
J. Nick Koston 4f086b6b25 Drive the push and poll halves off one define 2026-08-08 21:23:54 -05:00
J. Nick Koston c4850a9320 Note the tracker-native calls in the esp32 arm 2026-08-08 21:03:19 -05:00
J. Nick Koston 91bcb14c09 Restore merged pairing dispatch and capability doc 2026-08-08 21:02:10 -05:00
J. Nick Koston 85e4a6cc33 Merge remote-tracking branch 'origin/dev' into esp32-hub-devirtualize 2026-08-08 21:00:53 -05:00
J. Nick Koston 149d7cd801 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 19:14:28 -05:00
J. Nick Koston ce2e113a6d Update stale comments left by the listener removal 2026-08-08 19:13:11 -05:00
J. Nick Koston ae5b6d271f Retire the scanner-state listener interface 2026-08-08 19:13:11 -05:00
J. Nick Koston e1572d7520 Drop the duplicated scanner-state define 2026-08-08 16:36:16 -05:00
J. Nick Koston 536afce394 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/ble_device_base/ble_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 16:02:18 -05:00
J. Nick Koston 152b6a5d50 Update stale comments left by the listener removal 2026-08-08 15:59:54 -05:00
J. Nick Koston f68ebcf7d1 Retire the scanner-state listener interface 2026-08-08 15:59:54 -05:00
J. Nick Koston ca5fa6dfaf Use the shared MAC validity helper 2026-08-08 15:59:53 -05:00
J. Nick Koston 962a82d157 Make the adapter MAC accessor const 2026-08-08 15:59:53 -05:00
J. Nick Koston c6d4ca7377 Use one stack query for the adapter MAC 2026-08-08 15:59:53 -05:00
J. Nick Koston 465a6a3596 Let esp32_ble own the adapter MAC lookup 2026-08-08 15:59:53 -05:00
J. Nick Koston 03c9b001f4 Ask the stack for the adapter MAC on hosted controllers 2026-08-08 15:59:53 -05:00
J. Nick Koston d680200e5a Read the adapter MAC through esp_read_mac 2026-08-08 15:59:53 -05:00
J. Nick Koston 98dae49440 Remove redundant access specifier 2026-08-08 15:59:53 -05:00
J. Nick Koston 7ec366c1ba Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 15:59:53 -05:00
J. Nick Koston b1e1b0d7c5 Retire the raw listener path and parser-type enum from the tracker 2026-08-08 15:59:53 -05:00
J. Nick Koston ec49eb12dd Finish the docstring reflow 2026-08-08 11:28:16 -05:00
J. Nick Koston 949474efc6 Name the alias header in the module docstring 2026-08-08 10:55:27 -05:00
J. Nick Koston 708f095b6f Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 10:55:27 -05:00
J. Nick Koston bf4af703b6 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 10:55:25 -05:00
J. Nick Koston 541616e19c Use the shared MAC validity helper 2026-08-08 10:55:22 -05:00
J. Nick Koston 952c3e6584 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 09:52:26 -05:00
J. Nick Koston 2ea55bee7b Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 09:52:24 -05:00
J. Nick Koston 1931ef32ee Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 09:52:12 -05:00
J. Nick Koston f2da6c1a56 Merge remote-tracking branch 'origin/dev' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	tests/component_tests/ble_device_base/test_slot_counter.py
2026-08-08 09:51:50 -05:00
J. Nick Koston 6e9e83bdfd Include defines.h where its define is tested 2026-08-08 04:05:45 -05:00
J. Nick Koston fe29a53b71 Require the push slot in the concept where it exists 2026-08-08 03:20:11 -05:00
J. Nick Koston fa2dafcbca Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 02:43:49 -05:00
J. Nick Koston dc18fced19 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 02:43:47 -05:00
J. Nick Koston 389d3629c2 Make the adapter MAC accessor const 2026-08-08 02:43:45 -05:00
J. Nick Koston 36a4d87611 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 02:43:21 -05:00
J. Nick Koston 65db614f22 Pin the scanner-state callback slot semantics 2026-08-08 02:43:09 -05:00
J. Nick Koston 591856e7dc Constrain the contract's return types 2026-08-08 02:30:10 -05:00
J. Nick Koston b680482092 Trim the surface doc to the semantics 2026-08-08 02:01:49 -05:00
J. Nick Koston 7fe447a4a2 Enforce the hub surface with a concept 2026-08-08 02:00:17 -05:00
J. Nick Koston 8fae4f6767 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:54:40 -05:00
J. Nick Koston 3d458bd9c8 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:54:38 -05:00
J. Nick Koston fd7510d6e5 Use one stack query for the adapter MAC 2026-08-08 01:54:36 -05:00
J. Nick Koston ffe55f7691 Trim the new comments to repo style 2026-08-08 01:52:40 -05:00
J. Nick Koston 40ac344bf2 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 01:51:20 -05:00
J. Nick Koston 667ffe98d9 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:50:51 -05:00
J. Nick Koston 270f776675 Let esp32_ble own the adapter MAC lookup 2026-08-08 01:50:49 -05:00
J. Nick Koston 965bbd4425 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:46:50 -05:00
J. Nick Koston e485e5af90 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:46:48 -05:00
J. Nick Koston c795d0734d Ask the stack for the adapter MAC on hosted controllers 2026-08-08 01:46:45 -05:00
J. Nick Koston 0e77d47df4 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:38:39 -05:00
J. Nick Koston 47c2f8f31a Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:38:36 -05:00
J. Nick Koston fe53d24e46 Read the adapter MAC through esp_read_mac 2026-08-08 01:38:33 -05:00
J. Nick Koston daf988fde8 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:31:26 -05:00
J. Nick Koston 6604a9cca7 Update stale comments left by the listener removal 2026-08-08 01:31:22 -05:00
J. Nick Koston ddd45e393b Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:26:09 -05:00
J. Nick Koston 0974e8a1f5 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:26:07 -05:00
J. Nick Koston 1478307280 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 01:26:06 -05:00
J. Nick Koston fc77cdcb7d Drop a leftover declaration and fix a stale test name 2026-08-08 01:26:03 -05:00
J. Nick Koston fcb9da489e Call the static capability query through the type 2026-08-08 01:22:53 -05:00
J. Nick Koston ab458fffab Bind BLEHub to the build's tracker at compile time 2026-08-08 01:18:21 -05:00
J. Nick Koston c0c3afe6b1 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:02:41 -05:00
J. Nick Koston b092f64744 Remove redundant access specifier 2026-08-08 01:02:39 -05:00
J. Nick Koston 6b2938c1f7 Retire the scanner-state listener interface 2026-08-08 00:56:59 -05:00
J. Nick Koston 0e5d902c95 Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 00:53:31 -05:00
J. Nick Koston 11fc8e4c07 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:52:35 -05:00
J. Nick Koston d9f0f40373 Simplify scanner state: aliased enum, define-gated slot, one guarded sender 2026-08-08 00:52:12 -05:00
J. Nick Koston e671a602e9 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:41:34 -05:00
J. Nick Koston 7f44b9b826 Merge branch 'esp32-proxy-hub-advertisements' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
2026-08-08 00:41:33 -05:00
J. Nick Koston 47804f8d08 Address review: one tracker pointer, portable format, doc fixes 2026-08-08 00:40:43 -05:00
J. Nick Koston 67cc43c8ef Retire the raw listener path and parser-type enum from the tracker 2026-08-08 00:36:22 -05:00
J. Nick Koston 3e9808162b Deliver scanner state through a hub push callback 2026-08-08 00:31:18 -05:00
J. Nick Koston a6f81f3185 Carry the address as uint64 in RawAdvertisement and unify the proxy setup 2026-08-08 00:12:50 -05:00
J. Nick Koston 5296539849 Pin the removed listener slot 2026-08-08 00:01:43 -05:00
J. Nick Koston de9074fb5e Move the esp32 proxy advertisement path to the hub raw callback 2026-08-08 00:00:57 -05:00
248 changed files with 2798 additions and 12540 deletions

No files matched your search

+1 -1
View File
@@ -466,7 +466,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
with:
run: |
. venv/bin/activate
-38
View File
@@ -1,38 +0,0 @@
---
name: Nightly Dev Release
# Works out the dated dev tag and starts the release workflow with it, so that
# the release run is named after the tag it builds. A workflow run name is
# fixed when the run starts and cannot read a file or the current date.
on:
schedule:
- cron: "0 2 * * *"
permissions:
contents: read # actions/checkout to read the version from esphome/const.py
jobs:
trigger:
name: Start release build
if: github.repository == 'esphome/esphome'
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout to read the version from esphome/const.py
actions: write # gh workflow run starts release.yml
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Start the release workflow
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
if [[ -z "$VERSION" ]]; then
echo "::error::Could not read __version__ from esphome/const.py"
exit 1
fi
TAG="${VERSION}$(date --utc '+%Y%m%d')"
echo "Starting release build for ${TAG}"
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
+6 -30
View File
@@ -1,23 +1,12 @@
---
name: Publish Release
# Releases (production and beta) are named after the version they publish.
# Dev builds are named after the dated dev tag, which is passed in by the
# nightly workflow because a run name cannot compute it itself.
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
on:
workflow_dispatch:
inputs:
tag:
description: >-
Tag to build. Only supported on dev, where the nightly workflow
uses it. Leave empty to build the version from esphome/const.py
with today's date appended.
required: false
default: ""
release:
types: [published]
schedule:
- cron: "0 2 * * *"
permissions:
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
@@ -34,8 +23,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag
id: tag
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
# yamllint disable rule:line-length
run: |
if [[ "${{ github.event_name }}" = "release" ]]; then
@@ -47,23 +34,12 @@ jobs:
ENVIRONMENT="production"
fi
else
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
today="$(date --utc '+%Y%m%d')"
TAG="${TAG}${today}"
BRANCH=${GITHUB_REF#refs/heads/}
# The nightly workflow passes the finished tag so that the run name
# matches what is built. Without it, work it out here.
TAG="${INPUT_TAG}"
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
exit 1
fi
if [[ -z "$TAG" ]]; then
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
today="$(date --utc '+%Y%m%d')"
TAG="${TAG}${today}"
if [[ "$BRANCH" != "dev" ]]; then
TAG="${TAG}-${BRANCH}"
fi
fi
if [[ "$BRANCH" != "dev" ]]; then
TAG="${TAG}-${BRANCH}"
BRANCH_BUILD="true"
ENVIRONMENT=""
else
-6
View File
@@ -57,12 +57,6 @@ This document provides essential context for AI models interacting with this pro
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
these common names (for example the Realtek SDKs used by LibreTiny define
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
before the compiler sees it, breaking the build and clang-tidy on those platforms.
* **Python Idioms:**
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
-1
View File
@@ -238,7 +238,6 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004
+1 -1
View File
@@ -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.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
RUN \
platformio settings set enable_telemetry No \
+9 -11
View File
@@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
safe_print(
print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
@@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
safe_print(
print(
color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
)
@@ -2001,9 +2001,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
> 1
):
safe_print(
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
)
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
return 1
new_raw = re.sub(
@@ -2021,7 +2019,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
safe_print(
print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -2031,7 +2029,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
safe_print(
print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -2039,7 +2037,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
return 1
if new_path.exists():
safe_print(
print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
@@ -2047,7 +2045,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
)
return 1
safe_print(
print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
@@ -2056,7 +2054,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0:
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
return 1
@@ -2082,7 +2080,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
if CORE.config_path != new_path:
CORE.config_path.unlink()
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
+49 -109
View File
@@ -1,117 +1,26 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
the next upload/logs for that YAML reuses it instead of running the full
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
paths, UUIDs and enums store the same string form the YAML dumper
produced for them. JSON additionally coerces non-str dict keys to
strings; validated configs only use string keys (every schema key
validator is ``cv.string``). mtime gates staleness.
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
!lambda/!include/IDs/paths intact; mtime gates staleness.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from esphome.const import __version__ as ESPHOME_VERSION
from esphome.core import CORE, Lambda
from esphome.core import CORE
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# Bump when the on-disk shape changes; a mismatched version falls back
# to read_config. The envelope also stamps the writing esphome version:
# after an upgrade the cache holds the previous release's validation, so
# it falls back once and the re-save self-heals.
_CACHE_VERSION = 1
_LAMBDA_KEY = "__esphome_lambda__"
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because config validation resolved !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
try:
# The legacy YAML cache holds inline-resolved secrets and nothing
# reads it anymore; drop it even when the write below fails. A
# failed removal leaves resolved secrets on disk, so it warns.
try:
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
except OSError as err:
_LOGGER.warning(
"Could not remove the legacy validated-config cache: %s", err
)
rendered = json.dumps(
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
separators=(",", ":"),
default=_json_default,
)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except TypeError as err:
# Structural, not transient: this config can never cache (e.g. a
# non-basic dict key), so every upload/logs pays the slow path.
_LOGGER.warning("Cannot cache the validated config: %s", err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, a different cache
version, or the sidecar is incomplete. The loaded config carries no
source ranges; callers must not feed it into read_config/write_cpp.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
try:
envelope = json.loads(
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
)
except (OSError, ValueError) as err:
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
return None
if (
not isinstance(envelope, dict)
or envelope.get("v") != _CACHE_VERSION
or envelope.get("esphome") != ESPHOME_VERSION
or not isinstance(config := envelope.get("config"), dict)
):
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
# Remove before 2027.8: by then every maintained install has saved the
# JSON cache at least once and dropped its legacy YAML file.
def _legacy_compiled_config_path(config_filename: str) -> Path:
"""Path of the pre-JSON YAML cache; only ever removed."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
@@ -123,21 +32,52 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
return False
def _json_default(value: Any) -> Any:
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
IncludeFile/Extend/Remove have no JSON mirror and would stringify
wrong, but none survive validation (config.py's packages merge and
the substitution pass consume them) so no guard is spent on them.
Mode 0600 because show_secrets=True resolves !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
if isinstance(value, Lambda):
return {_LAMBDA_KEY: value.value}
return str(value)
from esphome import yaml_util
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def _decode_object(obj: dict[str, Any]) -> Any:
"""Revive the Lambda sentinel; every other mapping passes through."""
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
return Lambda(value)
return obj
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar
is incomplete.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
from esphome import yaml_util
try:
# 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
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
-72
View File
@@ -19,7 +19,6 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -244,12 +243,6 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
//
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
// have moved to that message as of API 1.15, but are still sent here so that
// older clients keep working. Do NOT mark them (deprecated) until the removal
// release: in this repo (deprecated) makes the generator drop the field
// entirely, so the device would stop sending it.
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -287,8 +280,6 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -297,14 +288,11 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
@@ -317,13 +305,10 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
@@ -332,63 +317,6 @@ message DeviceInfoResponse {
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
// ==================== DEVICE CAPABILITIES ====================
// Asks the device which optional features it supports.
//
// This message exists so that DeviceInfoResponse does not have to keep growing
// a flat list of feature flags. DeviceInfoResponse is served before
// authentication, so it is limited to identity information. Capabilities are
// only served on an authenticated connection (encrypted as well, when
// encryption is configured).
//
// Clients that see api_version >= 1.15 should read these values from
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
// Older clients keep reading DeviceInfoResponse, which still carries the same
// values, so this is not a breaking change.
message DeviceCapabilitiesRequest {
option (id) = 149;
option (source) = SOURCE_CLIENT;
// Empty
}
// Each feature gets its own sub-message so that it can gain fields over time
// without crowding the top-level field numbering.
//
// Note: a sub-message whose fields are all at their default value is not sent
// at all, so the presence of a sub-message is not a reliable test for "this
// feature is compiled in". Clients should test a value inside it, for example
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
message BluetoothProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
string mac_address = 2 [(max_data_length) = 17, (force) = true];
}
message VoiceAssistantCapabilities {
// Bitmask of the features this voice assistant supports
uint32 feature_flags = 1;
}
message ZWaveProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
uint32 home_id = 2;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest {
option (id) = 11;
option (source) = SOURCE_CLIENT;
+3 -37
View File
@@ -23,7 +23,6 @@
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
@@ -1736,7 +1735,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 15;
resp.api_version_minor = 14;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1850,7 +1849,8 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char bluetooth_mac[18];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
#endif
@@ -1904,35 +1904,6 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp);
}
bool APIConnection::send_device_capabilities_response_() {
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
// below in sync with send_device_info_response_() until those copies are removed.
DeviceCapabilitiesResponse resp;
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
#endif
#ifdef USE_VOICE_ASSISTANT
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
#endif
#ifdef USE_ZWAVE_PROXY
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
#endif
#ifdef USE_SERIAL_PROXY
size_t serial_proxy_index = 0;
for (auto const &proxy : App.get_serial_proxies()) {
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
break;
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
}
#endif
return this->send_message(resp);
}
void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(msg)) {
this->on_fatal_error();
@@ -1954,11 +1925,6 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error();
}
}
void APIConnection::on_device_capabilities_request() {
if (!this->send_device_capabilities_response_()) {
this->on_fatal_error();
}
}
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
-2
View File
@@ -266,7 +266,6 @@ class APIConnection final : public APIServerConnectionBase {
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -386,7 +385,6 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_();
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
+2 -2
View File
@@ -149,7 +149,7 @@ class APIFrameHelper {
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
@@ -312,7 +312,7 @@ class APIFrameHelper {
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
-76
View File
@@ -241,82 +241,6 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
return size;
}
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
return pos;
}
uint32_t BluetoothProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += 2 + this->mac_address.size();
return size;
}
#endif
#ifdef USE_VOICE_ASSISTANT
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
return pos;
}
uint32_t VoiceAssistantCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
return size;
}
#endif
#ifdef USE_ZWAVE_PROXY
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
return pos;
}
uint32_t ZWaveProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += ProtoSize::calc_uint32(1, this->home_id);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_BLUETOOTH_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
#endif
#ifdef USE_VOICE_ASSISTANT
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
#endif
#ifdef USE_ZWAVE_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
}
#endif
return pos;
}
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
uint32_t size = 0;
#ifdef USE_BLUETOOTH_PROXY
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
#endif
#ifdef USE_VOICE_ASSISTANT
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
#endif
#ifdef USE_ZWAVE_PROXY
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
}
#endif
return size;
}
#ifdef USE_BINARY_SENSOR
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
-68
View File
@@ -600,74 +600,6 @@ class DeviceInfoResponse final : public ProtoMessage {
protected:
};
#ifdef USE_BLUETOOTH_PROXY
class BluetoothProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef mac_address{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_VOICE_ASSISTANT
class VoiceAssistantCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint32_t home_id{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 102;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
#ifdef USE_BLUETOOTH_PROXY
BluetoothProxyCapabilities bluetooth_proxy{};
#endif
#ifdef USE_VOICE_ASSISTANT
VoiceAssistantCapabilities voice_assistant{};
#endif
#ifdef USE_ZWAVE_PROXY
ZWaveProxyCapabilities zwave_proxy{};
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
class ListEntitiesDoneResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 19;
-49
View File
@@ -988,55 +988,6 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
return out.c_str();
}
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
return out.c_str();
}
#endif
#ifdef USE_VOICE_ASSISTANT
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
#ifdef USE_ZWAVE_PROXY
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
this->bluetooth_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_VOICE_ASSISTANT
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
this->voice_assistant.dump_to(out);
out.append("\n");
#endif
#ifdef USE_ZWAVE_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
this->zwave_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
it.dump_to(out);
out.append("\n");
}
#endif
return out.c_str();
}
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str();
@@ -705,13 +705,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
case 149 /* DeviceCapabilitiesRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
#endif
this->on_device_capabilities_request();
break;
}
default:
break;
}
-2
View File
@@ -27,8 +27,6 @@ class APIServerConnectionBase {
void on_ping_response(){};
void on_device_info_request(){};
void on_device_capabilities_request(){};
void on_list_entities_request(){};
void on_subscribe_states_request(){};
-119
View File
@@ -1,119 +0,0 @@
// Every SDK call the scan reconciler makes. The BDK's own start hardcodes
// passive (the active bit is commented out in both stacks), so
// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field
// the SDK's app_ble_start_scaning() except that prop takes the mode, armed
// through the SDK's own operation bookkeeping. The component pins
// beken-bdk 3.0.78; the static asserts catch a layout change on a bump.
#include "bdk_scan.h"
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
// app_ble_actv_state_get, app_ble_env_state_get,
// app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX,
// bk_ble_* (via ble_api_5_x.h)
#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send
#if __has_include("gapm_msg.h")
#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_*
#else
#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name
#endif
}
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
// Pin the SDK surface this file depends on: a beken-bdk bump that moves these
// must fail the build, not corrupt the kernel message.
static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) &&
sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4,
"beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() "
"against the SDK's app_ble_start_scaning()");
static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX,
"beken-bdk activity sentinel changed; revalidate the scan reconciler");
static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 &&
GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5),
"beken-bdk GAPM report info changed; revalidate the tracker's demux constants");
bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; }
BdkActivityState bdk_scan_state(uint8_t activity_idx) {
if (activity_idx == INVALID_ACTIVITY_IDX)
return BdkActivityState::IDLE;
switch (app_ble_actv_state_get(activity_idx)) {
case ACTV_IDLE:
return BdkActivityState::IDLE;
case ACTV_SCAN_CREATED:
return BdkActivityState::CREATED;
case ACTV_SCAN_STARTED:
return BdkActivityState::STARTED;
default:
return BdkActivityState::OTHER;
}
}
uint8_t bdk_scan_acquire_activity() {
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (idx == INVALID_ACTIVITY_IDX)
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
return idx;
}
BdkOpResult bdk_scan_create(uint8_t activity_idx) {
ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr);
if (ret == ERR_SUCCESS)
return BdkOpResult::OK;
if (ret == ERR_BLE_STATUS)
return BdkOpResult::BUSY;
ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast<int>(ret));
return BdkOpResult::FAILED;
}
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) {
app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr);
struct gapm_activity_start_cmd *cmd =
KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd);
if (cmd == nullptr) {
app_ble_reset(); // the SDK's own failure path for an unsent operation
ESP_LOGE(TAG, "Scan start failed: kernel message allocation");
return BdkOpResult::FAILED;
}
cmd->operation = GAPM_START_ACTIVITY;
cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx;
cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER;
cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0);
cmd->u_param.scan_param.scan_param_1m.scan_intv = interval;
cmd->u_param.scan_param.scan_param_1m.scan_wd = window;
cmd->u_param.scan_param.scan_param_coded.scan_intv = 0;
cmd->u_param.scan_param.scan_param_coded.scan_wd = 0;
cmd->u_param.scan_param.dup_filt_pol = 0;
cmd->u_param.scan_param.rsvd = 0;
cmd->u_param.scan_param.duration = 0; // scan until stopped
cmd->u_param.scan_param.period = 10; // matches the SDK's passive start
kernel_msg_send(cmd);
return BdkOpResult::OK;
}
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr);
*err_out = static_cast<int>(ret);
if (ret == ERR_SUCCESS)
return BdkOpResult::OK;
// DEBUG on purpose: the reconciler WARNs once per streak and the stuck
// ERROR carries this code — a per-retry ERROR would be unbounded.
ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast<int>(ret));
return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED;
}
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // USE_BK72XX_BLE
-51
View File
@@ -1,51 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include <cstdint>
namespace esphome::bk72xx_ble {
/// Activity index value marking "no scan activity", the BDK's own convention
/// (asserted against its symbol in bdk_scan.cpp).
inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF;
/// Scan-relevant controller activity states, read live from the SDK.
enum class BdkActivityState : uint8_t {
IDLE, ///< No activity (or one whose create failed).
CREATED, ///< Created but not started.
STARTED, ///< Scanning.
OTHER, ///< A non-scan or transitional state; settles on a later read.
};
/// Outcome of a BDK scan operation request.
enum class BdkOpResult : uint8_t {
OK, ///< Accepted; completion is asynchronous.
BUSY, ///< Another controller operation is in flight; retry later.
FAILED, ///< Rejected.
};
/// True when no controller operation is in flight (APP_BLE_READY).
bool bdk_scan_ready();
/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE.
BdkActivityState bdk_scan_state(uint8_t activity_idx);
/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free.
uint8_t bdk_scan_acquire_activity();
/// Create the scan activity (asynchronous); started once CREATED is observed.
BdkOpResult bdk_scan_create(uint8_t activity_idx);
/// Start a created activity: the packed GAPM start, taking the scan mode the
/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the
/// kernel message could not be allocated (the armed SDK operation is rolled
/// back).
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active);
/// Release the activity: delete when never started (a stop would be
/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED
/// on any other error; err_out receives the SDK code (0 on success).
/// Teardown is asynchronous — observe IDLE to confirm.
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out);
} // namespace esphome::bk72xx_ble
#endif // USE_BK72XX_BLE
+36 -257
View File
@@ -5,8 +5,7 @@
// talks to the Beken BDK BLE stack:
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
// - the controller BLE address,
// - the scan reconciler (request, pacing, bring-up budget) over the
// bdk_scan surface,
// - the raw controller scan primitives (bk_ble_scan_start/stop),
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
// from 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
@@ -21,13 +20,10 @@
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release)
#ifdef USE_BK72XX_BLE
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h"
@@ -61,8 +57,9 @@
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
// ---------------------------------------------------------------------------
extern "C" {
#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t,
// BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp)
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
// app_ble_get_idle_actv_idx_handle, struct scan_param,
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
#include "common_bt_defines.h" // struct bd_addr
// The controller's public BLE address, populated by the BDK during ble_entry().
@@ -79,12 +76,6 @@ namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops
static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release
static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED
static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence
static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED)
// The BDK notice callback is a plain C function pointer with no user argument,
// so it reaches the (single) component instance through a file-static pointer.
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -104,22 +95,21 @@ static void ble_notice_callback(ble_notice_t notice, void *param) {
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
// a signed dBm value packed in a uint8_t).
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type,
static_cast<uint8_t>(info->evt_type), info->data, info->data_len);
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
info->data_len);
}
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type,
const uint8_t *data, uint16_t data_len) {
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop.
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
memcpy(report->mac, mac, 6);
report->rssi = rssi;
report->addr_type = addr_type;
report->evt_type = evt_type;
report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len);
@@ -133,9 +123,6 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add
void BK72xxBLE::setup() {
s_ble = this;
// The report pool grows lazily on purpose: the BDK notice callback runs in
// task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic
// stays far below the pool cap, so not warming contains RAM.
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
// the stack is up (it is re-read once ble_entry() has run).
this->resolve_mac_();
@@ -186,30 +173,6 @@ void BK72xxBLE::enable() {
}
void BK72xxBLE::loop() {
// Keep reconciling toward the requested scan state (e.g. complete a stop
// that arrived while a controller operation was in flight), and re-check a
// settled scan at low frequency: a controller-side drop re-enters the
// bring-up, and the budget's FAILED feeds the tracker's recovery.
// Keep driving until settled: any PENDING, plus a terminal stop whose slot
// must still be freed. A FAILED scan request is the one combination not
// re-driven here — that belongs to the tracker's backoff.
const uint32_t pump_now = App.get_loop_component_start_time();
if (this->last_result_ == ScanOpResult::PENDING ||
(!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) {
const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED)
? RECONCILE_REJECTED_RETRY_MS
: RECONCILE_RETRY_MS;
if (pump_now - this->last_advance_ms_ >= gate)
this->advance_();
} else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED &&
pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) {
// Re-check a settled scan; scan_start() refills the bring-up budget.
// WARN: the only report of a drop that recovers inside its budget.
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
ScanOpResult::SETTLED)
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
}
// Drain the lock-free ring filled by the BLE task; all per-report work runs
// here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop();
@@ -230,7 +193,7 @@ void BK72xxBLE::loop() {
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
@@ -263,7 +226,7 @@ void BK72xxBLE::resolve_mac_() {
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
return;
}
#endif
@@ -275,238 +238,54 @@ void BK72xxBLE::resolve_mac_() {
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently.
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
uint8_t wifi_mac[6];
get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - i];
}
// ---------------------------------------------------------------------------
// Scan reconciler
// Controller scan primitives
// ---------------------------------------------------------------------------
// Episode boundary: fresh teardown deadline and error bookkeeping.
void BK72xxBLE::reset_teardown_episode_() {
this->teardown_since_ms_ = 0;
this->restarting_ = false;
this->last_release_err_ = 0;
}
ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) {
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
if (!this->is_active())
this->enable();
const ScanParams params{active, interval, window};
// A new episode refills the budget and gets a fresh teardown deadline; a
// re-call observing an in-flight bring-up (last result PENDING) must not.
if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) {
this->pending_since_ms_ = App.get_loop_component_start_time();
this->reset_teardown_episode_();
if (this->scan_actv_idx_ != 0xFF) {
// Already scanning — stop first so this call cleanly restarts with the new
// parameters (the BDK cannot start a second scan on a busy activity).
this->scan_stop();
}
this->scan_wanted_ = true;
this->requested_ = params;
return this->advance_();
}
void BK72xxBLE::scan_stop() {
if (this->scan_wanted_) {
// A stamp inherited from a stuck restart would fail the stop on its
// first advance.
this->reset_teardown_episode_();
}
this->scan_wanted_ = false;
this->advance_();
}
struct scan_param sp;
memset(&sp, 0, sizeof(sp));
sp.channel_map = 7; // advertising channels 37/38/39
sp.interval = interval;
sp.window = window;
bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) {
// millis() on both sides: the loop clock is frozen while this blocks.
const uint32_t start = millis();
while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) {
if (millis() - start >= timeout_ms)
return false;
delay(RECONCILE_RETRY_MS);
this->advance_();
}
return this->last_result_ == ScanOpResult::SETTLED;
}
// Teardown is asynchronous: the handle is kept until an IDLE observation
// confirms the radio is idle. A rejection WARNs once per failure streak and
// widens the pump gate; the epilogue owns the stuck-teardown deadline.
void BK72xxBLE::release_activity_(BdkActivityState state) {
const BdkOpResult result =
bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_);
if (result == BdkOpResult::OK) {
this->release_warned_ = false;
return;
}
if (!this->release_warned_) {
// A hard error carries its code immediately; the 30 s stuck ERROR follows
// if it persists.
if (result == BdkOpResult::FAILED) {
ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_);
} else {
ESP_LOGW(TAG, "Scan activity release rejected; retrying");
}
this->release_warned_ = true;
}
}
// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged
// each interval) and report stuck.
bool BK72xxBLE::teardown_stuck_(uint32_t now) {
if (this->teardown_since_ms_ == 0) {
this->teardown_since_ms_ = now;
this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (this->scan_actv_idx_ == 0xFF) {
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
return false;
}
if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
if (ret != ERR_SUCCESS) {
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
this->scan_actv_idx_ = 0xFF;
return false;
if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) {
if (this->last_release_err_ != 0) {
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_);
} else {
// No rejected release this episode: stuck waiting on the controller.
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)");
}
this->teardown_stuck_log_ms_ = now;
}
return true;
}
// One SDK operation per call toward the latched request; controller state is
// read live each time (it changes on the BLE task, so nothing is mirrored).
// The epilogue owns all deadlines and episode bookkeeping.
ScanOpResult BK72xxBLE::advance_() {
if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
// Nothing to do; also keeps SDK reads off the pre-enable() path.
this->last_result_ = ScanOpResult::SETTLED;
return ScanOpResult::SETTLED;
void BK72xxBLE::scan_stop() {
if (this->scan_actv_idx_ != 0xFF) {
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
this->scan_actv_idx_ = 0xFF;
}
const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_);
const bool ready = bdk_scan_ready();
ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready);
const uint32_t now = App.get_loop_component_start_time();
this->last_advance_ms_ = now;
if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) {
// Any teardown episode is over (IDLE observed with the controller
// settled, or e.g. a mode flip that settled back without ever reaching
// IDLE). An IDLE read while an operation is in flight proves nothing —
// a stop deferred there must keep its episode running.
this->reset_teardown_episode_();
this->release_warned_ = false;
}
if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) {
// The mode-change release is observed complete; the rest is a normal
// bring-up on a fresh budget.
this->restarting_ = false;
this->pending_since_ms_ = now;
}
// Not chained to the clear above: a bring-up waiting at IDLE (create still
// in flight) must keep spending its budget.
if (result == ScanOpResult::PENDING) {
if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) {
// A downed radio spends the bring-up budget; exhausting it hands
// recovery to the tracker's backoff.
if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) {
ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start");
result = ScanOpResult::FAILED;
}
} else {
// A teardown is pending: a stop, or a mode-change release still in
// flight (restarting_); either way the bring-up budget waits.
if (this->scan_wanted_)
this->pending_since_ms_ = now;
if (this->teardown_stuck_(now)) {
// Terminal for stop AND restart: the tracker's backoff owns recovery
// (a stop's release keeps re-driving from loop(); a restart is
// re-requested through scan_start() with a fresh deadline).
result = ScanOpResult::FAILED;
}
}
}
this->last_result_ = result;
return result;
}
ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
if (state == BdkActivityState::IDLE && ready) {
// Fully torn down (or never created): the radio is idle. IDLE is trusted
// only when the controller is settled — mid-create the slot still reads
// IDLE, and dropping the handle then would leak the activity once the
// create lands.
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
return ScanOpResult::SETTLED;
}
if (!ready) {
// Acting mid-operation could delete an activity whose start lands
// afterwards, leaking the slot with the radio on; wait.
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
return ScanOpResult::PENDING;
}
// Settled, so CREATED unambiguously means "never started".
this->release_activity_(state);
return ScanOpResult::PENDING; // confirmed once IDLE is observed
}
ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
if (state == BdkActivityState::STARTED) {
if (this->applied_ == this->requested_)
return ScanOpResult::SETTLED;
// Running with different mode or parameters: tear down (the SDK stop
// chain also deletes the activity) and recreate on a later advance.
if (ready) {
this->release_activity_(state);
// Invalidate so a flip back to the old params cannot SETTLE against the
// activity being deleted (interval 0 never matches a real request).
this->applied_.interval = 0;
this->restarting_ = true;
}
return ScanOpResult::PENDING;
}
if (!ready) {
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
return ScanOpResult::PENDING;
}
if (state == BdkActivityState::CREATED) {
// Fire-and-forget: SETTLED only once a later advance observes the scan
// running, so a rejected start is retried rather than silently dead. On
// failure the created activity is intact; keep the handle.
if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window,
this->requested_.active) != BdkOpResult::OK)
return ScanOpResult::FAILED;
this->applied_ = this->requested_;
return ScanOpResult::PENDING;
}
if (state == BdkActivityState::OTHER)
return ScanOpResult::PENDING; // transitional; settles on a later read
// IDLE and ready: acquire a slot and create. A kept index is deliberately
// reused: SDK delete returns the slot to idle and create requires an idle
// slot, so it equals a fresh acquire — while clearing here would orphan a
// create still in flight (the BUSY race below).
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
this->scan_activity_idx_ = bdk_scan_acquire_activity();
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX)
return ScanOpResult::FAILED;
}
switch (bdk_scan_create(this->scan_activity_idx_)) {
case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot
case BdkOpResult::OK:
return ScanOpResult::PENDING;
case BdkOpResult::FAILED:
break;
}
// Safe to clear (unlike BUSY): acquire is a pure search, so a rejected
// create leaves the slot IDLE for re-acquire.
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
return ScanOpResult::FAILED;
}
} // namespace esphome::bk72xx_ble
+11 -63
View File
@@ -11,8 +11,6 @@
#include <cstdint>
#include "bdk_scan.h"
namespace esphome::bk72xx_ble {
enum class BLEComponentState : uint8_t {
@@ -21,32 +19,11 @@ enum class BLEComponentState : uint8_t {
ACTIVE,
};
/// Outcome of one reconciliation step.
enum class ScanOpResult : uint8_t {
SETTLED, ///< The request is reached: scan observed running, or stopped
///< with the activity fully released.
PENDING, ///< A step is in flight; loop() keeps advancing — call
///< scan_start() again to learn the outcome.
FAILED, ///< The controller rejected a step; retry later.
};
/// One scan request: mode plus timing, in BLE units (0.625 ms).
struct ScanParams {
bool active;
uint16_t interval;
uint16_t window;
bool operator==(const ScanParams &) const = default;
};
/// One advertisement report from the controller.
struct BLEScanReport {
uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
// GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type
// (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the
// tracker's merger tell the two frames apart.
uint8_t evt_type;
uint8_t data_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
@@ -83,7 +60,7 @@ class BK72xxBLE final : public Component {
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address, least-significant octet first (BLE convention).
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
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()).
@@ -92,33 +69,18 @@ class BK72xxBLE final : public Component {
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
#endif
/// Request a scan (interval/window in 0.625 ms BLE units); enables the
/// stack first if needed. PENDING until the scan is observed running —
/// loop() keeps advancing, call again to learn the outcome.
ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active);
/// Request the scanner stopped and the activity released; steps that
/// cannot run yet are completed from loop().
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
/// Enables the stack first if needed. Returns false on controller failure.
bool scan_start(uint16_t interval, uint16_t window);
/// Stop the controller scan (no-op when not scanning).
void scan_stop();
/// Drive a requested stop until the radio is observed idle, bounded by
/// timeout_ms (for OTA). Returns false if it still has not settled.
bool flush_pending_stop(uint32_t timeout_ms);
/// Last reconciliation outcome; on FAILED the consumer's retry policy owns
/// recovery.
ScanOpResult last_scan_result() const { return this->last_result_; }
/// Internal: buffer one controller report (BDK notice callback, BLE task
/// context — bounded copy under the scheduler lock, nothing else).
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data,
uint16_t data_len);
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
protected:
void resolve_mac_();
ScanOpResult advance_();
ScanOpResult advance_stop_(BdkActivityState state, bool ready);
ScanOpResult advance_start_(BdkActivityState state, bool ready);
bool teardown_stuck_(uint32_t now);
void reset_teardown_episode_();
void release_activity_(BdkActivityState state);
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
// Codegen-sized: no heap allocation, no std::vector template instantiation —
@@ -133,24 +95,10 @@ class BK72xxBLE final : public Component {
// 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_;
// Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
uint32_t last_advance_ms_{0};
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
ScanParams requested_{}; // latched by scan_start()
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention)
uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX};
bool scan_wanted_{false}; // the latched request is to scan (vs stopped)
bool release_warned_{false}; // gates the release WARN; widens the pump gate
bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released
bool enable_on_boot_{false};
// PENDING means advance_() has more to do; loop() drives it, paced and
// (for a bring-up) bounded.
ScanOpResult last_result_{ScanOpResult::SETTLED};
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
uint8_t scan_actv_idx_{0xFF};
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{false};
};
} // namespace esphome::bk72xx_ble
@@ -25,7 +25,6 @@ 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,
@@ -147,9 +146,6 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_BK72XX_BLE_TRACKER")
# Compiles the shared adv + scan-response merge (the BDK delivers the pair
# as separate reports).
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -168,7 +164,6 @@ async def to_code(config: ConfigType) -> None:
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_configured_continuous(scan[CONF_CONTINUOUS]))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
await ble_automation.advertise_trigger_to_code(conf, var)
@@ -9,9 +9,10 @@
#include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker {
@@ -26,15 +27,6 @@ static const char *const TAG = "bk72xx_ble_tracker";
// a single WARN is emitted when the retry interval first saturates.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
// Stable-run time before the failure streak clears; reset-on-start would keep
// a flapping controller at the 1 s gate.
static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000;
// Radio-idle deadline for the bounded stop drain at OTA start.
static constexpr uint32_t OTA_STOP_FLUSH_MS = 100;
// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part.
constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; }
// ---------------------------------------------------------------------------
// Component lifecycle
@@ -44,20 +36,11 @@ void BK72xxBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// BLE task and delivers here on the main task.
this->parent_->register_scan_listener(this);
// Merged (and unmerged) frames go to the shared dispatcher; unclaimed
// devices are logged only on one-shot scans (continuous would spam).
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
// scan_requested_ check: an on_boot start_scan latched before this setup()
// must keep the retry loop running (rp2/ln882h parity).
if (!this->scan_continuous_ && !this->scan_requested_) {
// Nothing to time until an explicit start_scan(); it re-enables the loop.
this->disable_loop();
}
}
#ifdef USE_OTA_STATE_LISTENER
@@ -67,54 +50,30 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_requested_before_ota_ = this->scan_requested_;
this->stop_scan();
// The transfer starves the loop; a deferred stop would leave the radio
// scanning for the whole update, so drain it here, bounded.
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch).
if (this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
this->scan_continuous_ = true;
this->enable_loop(); // stop_scan() parked it
}
// A one-shot request that was still pending (latched, retrying) when the
// OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
if (this->scan_requested_before_ota_) {
this->scan_requested_before_ota_ = false;
this->scan_requested_ = true;
this->enable_loop();
}
}
}
#endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout.
if (!this->merger_.empty())
this->merger_.sweep(now);
// Before the drop branch: a drop after a stable run starts a fresh streak.
if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
this->failed_start_count_ = 0;
// A terminal failure while we report running recovers via the normal retry
// path; the drop charges the backoff so a flapping controller escalates.
if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) {
ESP_LOGW(TAG, "Controller scan lost; retrying");
this->scan_requested_ = true;
this->count_failed_start_();
this->mark_scan_ended_(now);
}
const uint32_t now = millis();
if (this->scan_continuous_) {
if (!this->scan_running_) {
// One-iteration deferral; all stamps share this iteration's cached
// timestamp, so the period check below cannot underflow.
// A start that succeeded re-anchored the period timer from a later millis(),
// so the stale `now` below would underflow the comparison and fire
// on_scan_end() for a scan that just began. Resume next iteration.
if (this->try_start_with_backoff_(now))
return;
}
@@ -122,7 +81,11 @@ void BK72xxBLETracker::loop() {
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
// that never came up (start kept failing) does not fire spurious on_scan_end events.
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
this->fire_scan_end_();
#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;
}
return;
@@ -136,14 +99,13 @@ void BK72xxBLETracker::loop() {
// would be silent: the scan never runs, stop_scan_() is never reached and
// on_scan_end() never fires, leaving period-keyed consumers waiting forever.
if (this->scan_requested_ && !this->scan_running_) {
// Same one-iteration deferral as the continuous branch.
// Same stale-`now` hazard as the continuous branch: start_scan_() stamps
// scan_start_time_ from a later millis(), so the duration check below would
// underflow and stop the scan in the iteration that started it.
if (this->try_start_with_backoff_(now))
return;
}
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
// A full-duration run proves the controller healthy even when duration is
// shorter than SCAN_STABLE_RESET_MS.
this->failed_start_count_ = 0;
this->stop_scan_();
}
}
@@ -160,54 +122,32 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
// even user-initiated attempts respect the backoff, so a start_scan() action
// on a short cadence cannot hammer a failing controller; the attempt stays
// inside the failure accounting below either way.
// Mid bring-up, observe instead of re-issuing (the hub self-advances). A
// SETTLED outcome completes immediately; only fresh attempts after FAILED
// are rate-limited.
const auto hub = this->parent_->last_scan_result();
if (hub == bk72xx_ble::ScanOpResult::PENDING)
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
if ((!force || this->failed_start_count_ != 0) &&
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings))
return false;
if (hub == bk72xx_ble::ScanOpResult::FAILED) {
if (this->start_attempt_open_) {
// Our bring-up gave up asynchronously; charge it to the backoff.
this->start_attempt_open_ = false;
this->count_failed_start_();
}
if ((!force || this->failed_start_count_ != 0) &&
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
return false;
}
this->last_scan_start_attempt_ = now;
this->start_scan_();
if (!this->scan_running_) {
if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) {
this->start_attempt_open_ = true;
return false; // the controller is still bringing the scan up; not a failure
}
this->count_failed_start_();
}
return this->scan_running_;
}
void BK72xxBLETracker::count_failed_start_() {
if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
++this->failed_start_count_;
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
}
}
return this->scan_running_;
}
void BK72xxBLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"BK72xx BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Type: %s (configured %s)\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_,
ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
}
// ---------------------------------------------------------------------------
@@ -216,33 +156,31 @@ void BK72xxBLETracker::dump_config() {
// listener dispatch run in main-loop context with no cross-task handling here.
// ---------------------------------------------------------------------------
// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
// 5.2 fill it from gapm_ext_adv_report_ind.info).
static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
// Demux advertisements vs scan responses into the shared merger: the BDK
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
return;
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac),
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
// Stash only while an active scan runs: a passive scan never gets a
// response, and after a stop nothing would sweep the merger, so a late
// report would surface minutes later as a fresh advertisement.
if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
App.get_loop_component_start_time());
return;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
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)) {
found = true;
}
}
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
// 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
}
// ---------------------------------------------------------------------------
@@ -269,8 +207,7 @@ void BK72xxBLETracker::start_scan() {
// against a failing controller, repeated start_scan() calls are rate-limited
// like any other attempt.
this->scan_requested_ = true;
this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true);
this->try_start_with_backoff_(millis(), /* force= */ true);
}
void BK72xxBLETracker::restart_scan_duration() {
@@ -281,7 +218,7 @@ void BK72xxBLETracker::restart_scan_duration() {
// 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_ = App.get_loop_component_start_time();
this->scan_start_time_ = millis();
}
void BK72xxBLETracker::stop_scan() {
@@ -294,31 +231,24 @@ void BK72xxBLETracker::stop_scan() {
// Internal scan start / stop
// ---------------------------------------------------------------------------
bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() {
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
}
void BK72xxBLETracker::start_scan_() {
if (this->scan_running_)
return;
if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
return;
const uint32_t now = App.get_loop_component_start_time();
const uint32_t now = millis();
this->scan_running_ = true;
this->scan_requested_ = false; // the latched one-shot request is satisfied
this->start_attempt_open_ = false;
// failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too
this->scan_start_time_ = now;
// 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, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
ble_units_to_ms(this->scan_interval_));
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
this->scan_interval_ * 0.625f);
// Re-anchor the on_scan_end period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
@@ -328,48 +258,18 @@ void BK72xxBLETracker::start_scan_() {
this->scan_started_once_ = true;
}
// Deliberate logical/physical split: on_scan_end() reports the tracker's
// intent while the hub winds the radio down asynchronously; OTA is the one
// path that must wait, and it flushes explicitly.
void BK72xxBLETracker::stop_scan_() {
this->start_attempt_open_ = false; // an abandoned bring-up is not charged
this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
if (this->scan_running_) {
ESP_LOGD(TAG, "Scan stopped");
this->mark_scan_ended_(App.get_loop_component_start_time());
}
// Park when idle (the hub drives its own teardown); re-check because an
// on_scan_end automation may have restarted the scan.
if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
this->disable_loop();
}
// The period re-anchor keeps on_scan_end from double-firing in one iteration.
void BK72xxBLETracker::mark_scan_ended_(uint32_t now) {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->scan_running_ = false;
this->fire_scan_end_();
this->scan_period_start_ = now;
}
void BK72xxBLETracker::fire_scan_end_() {
// Deliver held advertisements whose scan response never came (unmerged)
// BEFORE on_scan_end fires.
this->merger_.flush();
this->dispatcher_.on_scan_end();
}
// true = request latched, not applied: the reconciler applies it
// asynchronously and loop() recovers a failed re-arm (ln882h parity).
bool BK72xxBLETracker::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");
// The controller reconciler restarts a running scan itself; the scan stays
// logically running. An idle scanner picks the mode up on its next start.
if (this->scan_running_)
this->controller_scan_start_();
return true;
ESP_LOGD(TAG, "Scan stopped");
#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_ = millis(); // reset period clock so on_scan_end does not double-fire
}
} // namespace esphome::bk72xx_ble_tracker
@@ -21,7 +21,6 @@
// window: 30ms
// duration: 5min
// continuous: true
// active: true
#pragma once
@@ -30,7 +29,6 @@
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -71,12 +69,6 @@ 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.active); runtime mode requests change
/// only the resolved mode.
void set_scan_active(bool scan_active) {
this->scan_active_ = scan_active;
this->scan_active_configured_ = scan_active;
}
/// 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) {
@@ -101,29 +93,37 @@ class BK72xxBLETracker : public Component,
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
this->dispatcher_.register_listener(listener);
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->dispatcher_.set_raw_advertisement_callback(callback);
this->raw_advertisement_callback_ = callback;
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// Active scanning is driven through bk72xx_ble's reconciler because the BDK
// API itself is passive-only. The controller delivers scan responses as
// separate reports; this tracker merges the pair before delivery (shared
// ScanResponseMerger, Bluedroid semantics). No GATT client.
return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true};
// The Beken BDK exposes no active-scan path (passive scanning only), so the
// 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.
// 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) {
// Passive-only controller: a passive request is already honored, an active
// one cannot be.
return !active;
}
bool request_scan_mode(bool active);
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[MAC_ADDRESS_SIZE];
void get_adapter_mac(uint8_t out[6]) {
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() { return this->scan_running_; }
bool scan_active() { return this->scan_active_; }
bool scan_active() { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
@@ -133,20 +133,15 @@ class BK72xxBLETracker : public Component,
protected:
void start_scan_();
void stop_scan_();
void fire_scan_end_();
void mark_scan_ended_(uint32_t now);
/// Stamp-and-start for every controller scan attempt, so the retry rate
/// limit covers all callers.
bk72xx_ble::ScanOpResult controller_scan_start_();
/// Rate-limited (re)start; true when the scan is running (the caller must
/// not reuse a `now` older than the stamps this refreshed). Force and
/// backoff rules are documented at the definition.
/// Attempt a rate-limited (re)start; returns true when the scan is running,
/// which means the caller must not compare its cached millis() against the
/// timestamps start_scan_() just refreshed. force bypasses the rate gate for
/// an explicit user start only while the failure streak is clean; a failing
/// controller rate-limits forced attempts too. Failure accounting always runs.
bool try_start_with_backoff_(uint32_t now, bool force = false);
void count_failed_start_();
bool scan_running_{false};
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
// Defaults: the BK reference — 30 % duty cycle
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
@@ -154,27 +149,30 @@ class BK72xxBLETracker : public Component,
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
bool scan_active_{true}; // resolved mode; see scan_parameters.active
bool scan_active_configured_{true}; // YAML value; runtime requests 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
#endif
uint32_t scan_start_time_{0};
uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries
uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop())
uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end()
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main task (the controller queue already crossed
// tasks). Merger clock: stash_adv() reads the PARENT's cached loop time
// (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this
// component's — same App.loop() pass, so the delta stays non-negative and
// the 300 ms timeout holds.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
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_;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// 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::bk72xx_ble_tracker
@@ -163,9 +163,8 @@ _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 compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT;
distinct from the proxy's validated connection budget). Called by
bluetooth_connection.new_gatt_backend() once per backend instance."""
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()
@@ -247,23 +246,24 @@ def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
supports_active: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
unconditional: active scanning is part of the tracker contract — every
current proxy client assumes it, so a passive-only tracker must not share
this schema.
LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when
the tracker supports active scanning; it exposes the `active` option
(whose own default is on, esp32_ble_tracker behavior).
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if supports_active:
schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean
return cv.All(cv.Schema(schema), validate_scan_parameters)
@@ -17,17 +17,11 @@ namespace esphome::ble_device_base {
/// client backend.
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
static constexpr int GATT_ERR_NO_MEMORY = -2;
/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency,
/// e.g. a service table failing its own bounds checks.
static constexpr int GATT_ERR_UNLIKELY = 0x0E;
/// Safety net shared by every GATT backend: force IDLE when the stack never
/// delivers its disconnect completion.
static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000;
/// ATT MTU before negotiation completes (Bluetooth spec default).
static constexpr uint16_t DEFAULT_ATT_MTU = 23;
// Preferred connection parameters shared by every platform's GATT client so
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
// 0). FAST covers connection setup and service discovery; MEDIUM is the
@@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &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_to() then produce exactly the historical esp32 values.
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
uint8_t mac_lsb_first[6];
for (uint8_t i = 0; i < 6; i++)
mac_lsb_first[i] = scan_result.bda[5 - i];
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
@@ -241,7 +241,7 @@ class ESPBTDevice {
// 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_[MAC_ADDRESS_SIZE]{0};
uint8_t address_[6]{0};
uint8_t address_type_{0};
int rssi_{0};
// Fixed buffer instead of std::string: no per-advertisement heap churn on
@@ -5,11 +5,14 @@
// Exactly one GATT backend exists per build, so BLEGattConnection is a
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
// interface.
// A consumer - the hub wrapper streaming the raw database, or a direct
// consumer owning a dedicated backend and resolving handles by UUID -
// drives it and receives completions through the GattClientListener
// interface. All listener calls are delivered on the ESPHome main loop;
// borrowed data pointers are valid only for the duration of the call.
// A consumer - a streaming consumer that forwards the raw database (the hub
// BluetoothConnection wrapper) or a direct consumer owning a dedicated
// backend and resolving handles by UUID - drives it and receives
// completions through the GattClientListener interface (one build can hold
// several consumer types while the backend stays a single non-virtual
// class). All listener
// calls 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
@@ -79,12 +82,17 @@ struct GattServiceTable {
uint16_t descriptor_count{0};
};
/// The event surface a backend delivers completions through - the one place
/// with genuine runtime polymorphism (several consumer types, one non-virtual
/// backend). Methods default to no-ops; consumers override what they consume.
/// No destructor: components are never destroyed.
/// on_connection_state carries the negotiated MTU and an HCI status/reason.
/// Codegen wires the listener before setup(), so backends skip null checks.
/// The event surface a backend delivers completions through. Genuine runtime
/// polymorphism lives here - one build can hold several consumer types (the
/// proxy's connection wrapper, dedicated-backend components) against the one
/// non-virtual backend class - so this is a plain virtual interface: every
/// method defaults to a no-op, consumers override what they consume (override
/// makes a misspelled name a compile error), and adding an event touches no
/// existing consumer. No destructor: components are never destroyed, and
/// nothing deletes through this base.
/// on_connection_state carries the negotiated MTU and an HCI
/// status/disconnect reason. Codegen wires the listener before setup(), so
/// backends may call without a null check.
class GattClientListener {
public:
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
@@ -102,13 +110,7 @@ class GattClientListener {
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// - gatt_disconnect: also cancels a connect in progress (named to coexist
// with a platform stack's own void disconnect() on one backend class).
// Nonzero means nothing to tear down and no completion will follow; an
// accepted teardown (0) always reaches a terminal on_connection_state.
// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not
// started closing - the in-flight connect resumes and completes normally.
// False once the teardown owns the link (or nothing was scheduled).
// - disconnect: also cancels a connect in progress.
// - notify_characteristic: local registration only; the CCCD write is the
// API client's responsibility (a plain write_descriptor).
// - get_service_table/release_services: backend-owned transient storage,
@@ -117,17 +119,16 @@ class GattClientListener {
// the concrete type, detected by the consumer at compile time) for
// arbitrary-size databases; the table then materializes only for consumers
// that ask for it.
// - completions: connect and gatt_disconnect land in on_connection_state,
// - completions: connect and disconnect land in on_connection_state,
// discover_services in on_service_discovery_done, pair in
// on_pairing_result, reads in on_read_result, notify_characteristic in
// on_notify_state, characteristic writes (with and without response) and
// descriptor writes in on_write_result.
// on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result.
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.gatt_disconnect() } -> std::same_as<int>;
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
@@ -143,6 +144,51 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)
return &table.services[i];
}
return nullptr;
}
inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
uint16_t end = service.first_characteristic + service.characteristic_count;
if (end > table.characteristic_count)
return nullptr;
for (uint16_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count;
if (end > table.descriptor_count)
return 0;
const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID);
for (uint16_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == cccd_uuid)
return table.descriptors[i].handle;
}
return 0;
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
+3 -3
View File
@@ -83,9 +83,9 @@ struct HubCapabilities {
/// Today: esp32 and rp2.
bool gatt;
/// request_scan_mode() is honored at runtime. Distinct from active_scan:
/// a passive-only controller 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).
/// 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;
};
@@ -1,153 +0,0 @@
#include "scan_response_merger.h"
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "esphome/core/helpers.h"
#include <cstring>
namespace esphome::ble_device_base {
void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, bool raw_only) {
// A partial bind is treated as unbound; never dereference half a binding.
if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr)
return;
this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only,
*this->scan_continuous_ ? nullptr : this->log_tag_);
}
void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, uint32_t now) {
// 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 == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 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->deliver_(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->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false);
return;
}
slot->used = true;
this->pending_count_++;
memcpy(slot->mac, mac, MAC_ADDRESS_SIZE);
slot->addr_type = addr_type;
slot->rssi = rssi;
slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data);
memcpy(slot->data, data, slot->data_len);
slot->stored_ms = now;
}
void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len) {
// Fast-out on the empty table (sweep/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 == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 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 = (data_len <= room) ? data_len : room;
memcpy(p.data + p.data_len, data, add);
p.used = false;
this->pending_count_--;
// The advertisement's RSSI, not the scan response's (header contract).
this->deliver_(mac, p.rssi, 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->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true);
}
void ScanResponseMerger::sweep(uint32_t now) {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void ScanResponseMerger::flush() {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag) {
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_callback_.is_set()) {
const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac),
.data = data,
.data_len = data_len,
.rssi = rssi,
.addr_type = addr_type};
this->raw_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;
ESPBTDevice device;
device.from_scan_result(mac, rssi, addr_type, data, data_len);
// The listener list holds sensors AND the 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;
}
}
if (!found && log_unclaimed_tag != nullptr)
this->discovered_log_.log_device(log_unclaimed_tag, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
void AdvDispatcher::on_scan_end() {
#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
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -1,152 +0,0 @@
// Shared support for trackers whose controller delivers advertisement and
// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates
// both into one result before ESPHome sees it):
//
// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is
// held briefly, its scan response is appended on arrival and the pair is
// delivered as ONE merged frame. Merged delivery is what the receiving side
// is built around: Home Assistant keeps the latest raw frame per device and
// skips re-parsing when it is unchanged — split delivery alternates two raw
// frames per device and defeats both.
//
// AdvDispatcher — the delivery half every such tracker repeats: raw
// callback, listener parsing, discovered-device log. Trackers delegate
// their BLEHub register_listener / set_raw_advertisement_callback here.
//
// The merger delivers straight into the tracker's AdvDispatcher — bind() wires
// the pair once in setup(). Single-task use only (every tracker calls this on
// the ESPHome main task). The clock is caller-provided: pass the same clock to
// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(),
// never mixed).
#pragma once
#include "esphome/core/defines.h"
// Emitted (cg.add_define) by each tracker that adopts the merger, so builds
// whose tracker merges in-stack (esp32) never compile this code.
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/helpers.h"
#include <cstdint>
namespace esphome::ble_device_base {
/// The delivery half of a split-report tracker, shared so the dispatch
/// contract (raw-callback ordering, raw_only gate, discovered-log policy)
/// lives in one place. Owns the members every tracker otherwise duplicates;
/// the tracker's BLEHub methods delegate here.
class AdvDispatcher {
public:
void register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; }
/// 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
/// parsed for local sensors/triggers (Home Assistant merges per address).
/// log_unclaimed_tag: when non-null, a device no listener claimed is logged
/// under this tag (esp32_ble_tracker parity: pass the tracker TAG on
/// one-shot scans, nullptr on continuous scans, which would spam).
void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag);
/// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup.
void on_scan_end();
protected:
RawAdvertisementCallback raw_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<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
// Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only
// writer so a no-listener build does not carry an unused vector.
DiscoveredDeviceLog discovered_log_{};
#endif
};
class ScanResponseMerger {
public:
/// Wire the merger's output; call once in the tracker's setup(). Every
/// delivered frame goes to dispatcher->dispatch(); scan_continuous is read
/// at each delivery (runtime continuous flips are honored) to decide the
/// unclaimed-device log tag, so both pointers must outlive the merger —
/// tracker members always do.
void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) {
this->dispatcher_ = dispatcher;
this->scan_continuous_ = scan_continuous;
this->log_tag_ = log_tag;
}
/// Hold a scannable advertisement, waiting for its scan response. The
/// tracker calls this only when it wants the merge (scannable advertisement
/// while an active scan runs) and delivers everything else directly. A
/// same-device re-advertisement delivers the held frame (its scan response
/// is not coming) and reuses the slot; a full table degrades gracefully to
/// unmerged delivery.
void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
uint32_t now);
/// A scan response arrived: append it to the held advertisement from the
/// same device and deliver the pair as one frame. The merged frame reports
/// the ADVERTISEMENT's RSSI — every unmerged path reports the
/// advertisement's measurement, so a device's RSSI must not jump between two
/// measurements depending on merge timing. Unmatched responses are delivered
/// raw_only.
void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len);
/// Timeout flush (call from loop() with the stash_adv() clock): deliver
/// held advertisements whose scan response never arrived (device didn't
/// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS.
void sweep(uint32_t now);
/// Deliver every held advertisement now (scan period/scan is ending, before
/// on_scan_end fires): unmerged delivery, same as the timeout path.
void flush();
/// Lets loop() skip the cross-TU sweep() call in the common case (empty:
/// passive scan, or every pair already matched).
bool empty() const { return this->pending_count_ == 0; }
private:
/// All delivery funnels through here: an unbound merger (bind() not called)
/// drops the frame instead of jumping through a null pointer, mirroring the
/// guard-before-invoke convention of the ble_hub.h callback slots.
void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only);
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum
// as ESP-IDF delivers on ESP32.
struct PendingAdv {
bool used{false};
uint8_t mac[MAC_ADDRESS_SIZE];
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 report-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 under WiFi/BLE coexistence,
// measured on-device (ln882h) 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;
AdvDispatcher *dispatcher_{nullptr};
const bool *scan_continuous_{nullptr}; // read at delivery; see bind()
const char *log_tag_{nullptr};
// pending_count_ mirrors the number of set `used` flags; both are updated
// together on every transition.
PendingAdv pending_adv_[MAX_PENDING_ADV];
uint8_t pending_count_{0};
};
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -1,29 +1,35 @@
"""Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
Bluetooth proxy's codegen declares and registers the backend instances
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
consumer's codegen declares and registers the backend instances — the
Bluetooth proxy through its per-slot connection wrappers (a streaming
consumer), and direct consumers owning a dedicated backend through
gatt_client_config_schema() + new_gatt_backend().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components import rp2040_ble
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
from esphome.const import (
CONF_MAC_ADDRESS,
PLATFORM_ESP32,
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
DOMAIN = "bluetooth_connection"
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with (the Bluedroid header includes the tracker's), so
consumers need not know. The platform-less arm serves manifest tooling."""
registers with, so consumers stay platform-blind. The platform-less arm
serves tooling that resolves the manifest without a target."""
if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.is_rp2:
@@ -37,15 +43,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
# belongs to the platform stack that owns the pools.
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
# raising this needs an upstream change (the layer itself supports N).
RP2_MAX_CONNECTIONS = 1
# Slot limits for the hub platforms running the connection-capable proxy;
# the backend registry itself is _PLATFORM_BACKENDS below.
# Hub platforms with a GATT backend, mapped to their slot limit — the single
# registry of which hub platforms run the connection-capable proxy.
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
# The hub-platform wrapper and the backend codegen classes.
@@ -56,19 +59,10 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
)
CONF_BACKEND_ID = "backend_id"
CONF_ADDRESS_TYPE = "address_type"
DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
def _get_data() -> _ConnectionData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _ConnectionData()
return CORE.data[DOMAIN]
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
ADDRESS_TYPES = {"public": 0, "random": 1}
def _esp32_schema_fragment() -> cv.Schema:
@@ -78,6 +72,8 @@ def _esp32_schema_fragment() -> cv.Schema:
def _rp2_schema_fragment() -> cv.Schema:
from esphome.components import rp2040_ble
return cv.Schema(
{cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)}
)
@@ -86,35 +82,21 @@ def _rp2_schema_fragment() -> cv.Schema:
async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import esp32_ble_tracker
# The tracker's promote loop owns connect timing; the backend registers
# as a raw client (it is the tracker's ESPBTClient).
await esp32_ble_tracker.register_raw_client(backend, config)
# The tracker's promote loop owns connect timing; the backend's
# tracker-facing shim registers as a raw client.
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import ota
from esphome.components import rp2040_ble
# The backend drops its link when an OTA starts (esp32 tracker parity).
ota.request_ota_state_listeners()
# More than one backend outgrows the prebuilt BTstack pools: swap them for
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
# btstack_memory.cpp. Keyed to backend registrations (the same event that
# grows the count that sizes the pools), so single-backend builds emit no
# flags and stay byte-identical to previous releases.
data = _get_data()
data.rp2_backend_count += 1
if data.rp2_backend_count == 2:
rp2040_ble.add_btstack_pool_overrides()
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
@dataclass(frozen=True)
class _PlatformBackend:
"""One platform's backend: codegen class, extra schema keys, and stack
registration. The esp32 fragments import their stack lazily because those
imports register esp32-only automations as a side effect; rp2040_ble is
side-effect-free, so it is imported at module scope (the cap constant
needs it there anyway)."""
"""One platform's backend: codegen class, extra schema keys (lazy so the
platform stack is only imported when targeted), and stack registration."""
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
@@ -131,6 +113,9 @@ _PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
}
# Gates dedicated-backend consumers (cv.only_on).
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
key = platform if platform is not None else CORE.target_platform
@@ -155,49 +140,131 @@ def gatt_client_schema(platform: str | None = None) -> cv.Schema:
def hub_connection_schema(platform: str | None = None) -> cv.Schema:
"""Per-slot schema for the proxy's connection wrappers: the wrapper id on
top of the backend fragment, plus the component keys (setup_priority and
friends now apply to the backend, the slot's real Component). Same
platform rules as gatt_client_schema()."""
return (
gatt_client_schema(platform)
.extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)})
.extend(cv.COMPONENT_SCHEMA)
top of the backend fragment. Same platform rules as gatt_client_schema()."""
return gatt_client_schema(platform).extend(
{cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}
)
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
@dataclass
class _SlotLedger:
"""GATT connection slots claimed this run, for the platform cap check."""
consumers: list[str] = field(default_factory=list)
def _ledger() -> _SlotLedger:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _SlotLedger()
return CORE.data[DOMAIN]
def consume_gatt_slot(consumer: str, count: int = 1):
"""Validator claiming GATT connection slots — the one spelling for every
claimant (the proxy per configured slot, dedicated backends once). The
neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA;
esp32 additionally charges the controller's connection budget."""
def validator(config: ConfigType) -> ConfigType:
_ledger().consumers.extend([consumer] * count)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
return config
return validator
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# esp32 has its own controller budget (esp32_ble); the hub platforms cap
# at the prebuilt stack's client count, and nothing else counts claims
# across components (e.g. a proxy plus a radon_eye_rd200 on rp2).
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
return config
claimed = _ledger().consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
# The peer keys every dedicated-backend consumer shares: one target device.
_PEER_SCHEMA = cv.Schema(
{
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum(
ADDRESS_TYPES, lower=True
),
}
)
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str):
"""Wrap a dedicated-backend consumer's schema so the consumer stays
platform-blind: gates on the platforms with a backend, folds in
gatt_client_schema() plus the peer keys (mac_address, address_type),
and claims the connection slot. `consumer` names the component in
slot-exhaustion errors."""
@schema_extractor("schema")
def apply(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform; expose the
# consumer's keys plus the platform-free peer keys.
return base_schema.extend(_PEER_SCHEMA)
cv.only_on(GATT_CLIENT_PLATFORMS)(config)
schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema())
config = schema(config)
return consume_gatt_slot(consumer)(config)
return apply
async def new_gatt_backend(
config: ConfigType, *, service_table: bool = True
) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema() and register
it with its platform stack. The connection slot is claimed at validation
(the proxy's slot validators), not here.
(gatt_client_config_schema / the proxy's slot validators), not here.
service_table compiles the on-demand service-table materializer into the
backend; direct consumers need it, the streaming proxy does not, so
proxy-only builds keep the smaller footprint.
"""
from esphome.components import ble_device_base
ble_device_base.request_gatt_client()
if service_table:
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend is the slot's real Component: component keys from the
# connection entry (setup_priority, ...) apply to it. Consumers whose own
# schema carries keys that register_component would misapply to the
# backend (e.g. a polling interval) must not put them in this config.
await cg.register_component(backend, config)
# The backend has no user-facing component options; an empty config keeps
# the consumer's own keys (update_interval, ...) off it.
await cg.register_component(backend, {})
await _backend_entry().register(backend, config)
return backend
# Named so tests can pin the hub entry against bluetooth_proxy's platform
# list (this module cannot import bluetooth_proxy to derive it).
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
"bluetooth_connection_hub.cpp": {
PlatformFramework.RP2_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
}
FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS)
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"bluetooth_connection_bluedroid.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
"bluetooth_connection_hub.cpp": {
PlatformFramework.RP2_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
}
)
@@ -1,11 +1,11 @@
#include "bluetooth_connection.h"
#ifdef USE_ESP32
#include <esp_gap_ble_api.h>
#include <esp_bt_device.h>
#include <esp_gattc_api.h>
#endif
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/log.h"
@@ -44,7 +44,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
namespace esphome::bluetooth_connection {
@@ -16,16 +16,16 @@
#include <esp_err.h>
#endif
// The connection-aware API request handlers are compiled: a GATT backend is
// wired by codegen (one slot per connection). This is the single spelling of
// that predicate - the hub wrapper and the API request handlers gate on it.
// The wrapper serves the proxy's API surface, so it compiles only when a
// backend AND the proxy are present; advertisement-only and backend-only
// builds get the clean-error handlers instead. Address-scoped maintenance
// (unpair, cache clear) still works there through the per-platform free
// functions below.
// The proxy-serving surface is compiled: a proxy is present and a GATT
// backend is wired by codegen (one slot per connection). This is the single
// spelling of that predicate - the hub wrapper, the connection-aware API
// request handlers, and the Bluedroid in-place streamer all gate on it.
// Advertisement-only builds get the clean-error handlers; address-scoped
// maintenance (unpair, cache clear) still works there through the
// per-platform free functions below. Backend-only builds (a dedicated-backend
// consumer without bluetooth_proxy) compile none of this API surface.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#define BLUETOOTH_CONNECTION_HAS_GATT
#define BLUETOOTH_CONNECTION_SERVES_PROXY
#endif
namespace esphome::api {
@@ -82,16 +82,6 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
// send_service_ cursor states; >= 0 is the next service index to stream.
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed
// Every sentinel must stay below the >= 0 streaming gate and clear of
// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused.
static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0);
static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED &&
SERVICES_DONE_PENDING != GATT_NOT_CONNECTED);
// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done
// delivered near the client's 30 s timeout could land on a fresh request's
// empty accumulator and cache as an empty database.
static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30;
// ---- Service-streaming size budget, shared by every platform's streamer ----
@@ -151,7 +141,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
}
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Result of close_service_batch: keep filling the batch or send it now.
/// An oversized service is packed alone; a failed (backpressured) send is
/// retried from the batch start, so no service is silently skipped.
@@ -163,6 +153,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
/// cannot drift.
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str);
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
} // namespace esphome::bluetooth_connection
@@ -2,10 +2,11 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY
#include "bluetooth_connection.h"
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -16,6 +17,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <esp_gatt_common_api.h>
#include <cstring>
namespace esphome::bluetooth_connection {
@@ -31,10 +34,19 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
using esp32_ble_tracker::ClientState;
using esp32_ble_tracker::ConnectionType;
// ---- tracker surface ----
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
void BluedroidGattClient::connect() { this->tracker_connect_(); }
void BluedroidGattClient::disconnect() { this->gatt_disconnect(); }
// ---- shim forwarders ----
bool BluedroidTrackerShim::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
return this->engine_->handle_gattc_event_(event, gattc_if, param);
}
void BluedroidTrackerShim::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
this->engine_->handle_gap_event_(event, param);
}
void BluedroidTrackerShim::connect() { this->engine_->tracker_connect_(); }
void BluedroidTrackerShim::disconnect() { this->engine_->disconnect(); }
// ---- component ----
@@ -45,45 +57,30 @@ void BluedroidGattClient::setup() {
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then re-register the app on the next enable.
auto down_st = this->state();
if (down_st != ClientState::IDLE && down_st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
this->set_state(ClientState::INIT);
// Stack down: re-register the app on the next enable.
this->set_state_(ClientState::INIT);
return;
}
auto st = this->state();
auto st = this->state_();
if (st == ClientState::INIT) {
// Parity with BLEClientBase: a failed registration marks the slot
// failed and idles it without retry.
auto ret = esp_ble_gattc_app_register(this->app_id);
auto ret = esp_ble_gattc_app_register(this->shim_.app_id);
if (ret) {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret);
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
this->set_state_(ClientState::IDLE);
} else if (st == ClientState::IDLE) {
// The loop only drives the bootstrap and the disconnect safety timeout.
this->disable_loop();
} else if (st == ClientState::DISCONNECTING &&
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGE(TAG, "[%d] Timeout waiting for CLOSE_EVT, forcing IDLE", this->connection_index_);
// Release before idling: unconditional disconnect does not release, and a
// lost CLOSE/DISCONNECT would otherwise leak the table and the cache.
this->release_services();
this->set_idle_();
} else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) {
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
// teardown whose OPEN_EVT never arrives.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
// Release before idling: a lost completion must not leak the cache.
this->release_services();
this->set_idle_(); // also clears want_disconnect_
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
}
} else {
// The loop stays on while a link exists (stack-down watch, pre-started
// search flush); it settles only back at IDLE.
this->deliver_pending_search_();
if (this->state() == ClientState::IDLE) {
this->disable_loop();
}
this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT);
}
}
@@ -97,9 +94,10 @@ void BluedroidGattClient::dump_config() {
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
// Only from idle: clobbering DISCONNECTING would open a new link the
// stale CLOSE_EVT then tears down.
if (this->state() != ClientState::IDLE) {
// Refuse anything but a fully idle slot. Clobbering DISCONNECTING with
// DISCOVERED would let the tracker open a new link while the old one is
// still closing - the stale CLOSE_EVT then tears the new attempt down.
if (this->state_() != ClientState::IDLE) {
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
return ESP_GATT_BUSY;
}
@@ -107,12 +105,12 @@ int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
this->remote_addr_type_ = addr_type;
// Hand the request to the tracker's promote loop: it stops the scan, raises
// coex, and calls tracker_connect_() - the tracker owns connect timing here.
this->set_state(ClientState::DISCOVERED);
this->set_state_(ClientState::DISCOVERED);
return 0;
}
void BluedroidGattClient::tracker_connect_() {
auto st = this->state();
auto st = this->state_();
if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_);
return;
@@ -122,35 +120,30 @@ void BluedroidGattClient::tracker_connect_() {
return;
}
ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_);
// Per-attempt latches; the search machine is reset by set_idle_(), the
// one door back to IDLE.
this->services_released_ = false;
this->seen_mtu_ = false;
this->mtu_failed_ = false;
this->enable_loop();
this->set_state(ClientState::CONNECTING);
this->set_state_(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL,
FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT));
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
FAST_CONN_TIMEOUT);
} else {
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL,
MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT));
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0,
MEDIUM_CONN_TIMEOUT);
}
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_,
static_cast<esp_ble_addr_type_t>(this->remote_addr_type_), true);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
// CONNECT_EVT never fired; nothing to close.
this->set_idle_();
this->listener_->on_connection_state(false, 0, ret);
// CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE.
this->set_state_(ClientState::IDLE);
this->report_connection_state_(false, ret);
}
}
int BluedroidGattClient::gatt_disconnect() {
auto st = this->state();
int BluedroidGattClient::disconnect() {
auto st = this->state_();
if (st == ClientState::DISCONNECTING) {
return 0;
}
@@ -161,15 +154,12 @@ int BluedroidGattClient::gatt_disconnect() {
}
if (st == ClientState::DISCOVERED) {
// Parked for the tracker promote loop, never opened.
this->set_idle_();
this->set_state_(ClientState::IDLE);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_);
this->want_disconnect_ = true;
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
this->disconnecting_started_ = millis();
this->enable_loop();
this->shim_.schedule_disconnect();
return 0;
}
this->unconditional_disconnect_();
@@ -179,11 +169,7 @@ int BluedroidGattClient::gatt_disconnect() {
void BluedroidGattClient::unconditional_disconnect_() {
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
if (this->conn_id_ == UNSET_CONN_ID) {
// Terminal state now rather than leaning on the scheduled-teardown timer.
ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_);
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
return;
}
auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_);
@@ -194,58 +180,20 @@ void BluedroidGattClient::unconditional_disconnect_() {
this->set_disconnecting_();
}
bool BluedroidGattClient::cancel_gatt_disconnect() {
// Only a scheduled teardown (want_disconnect_ latched while the open is
// still in flight) is cancellable; once closing started the terminal
// report settles the race.
if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) {
return false;
}
this->want_disconnect_ = false;
return true;
}
int BluedroidGattClient::discover_services() {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
switch (this->search_state_) {
case SearchState::PRESTARTED:
// The pending SEARCH_CMPL reports once it lands.
this->search_state_ = SearchState::CLAIMED;
return 0;
case SearchState::PRESTART_DONE:
// Already landed: the flush after the connected report delivers
// (loop() covers a claim made outside that event drain).
this->search_state_ = SearchState::REPORT_PENDING;
this->enable_loop();
return 0;
case SearchState::CLAIMED:
case SearchState::REPORT_PENDING:
return 0; // One completion is already owed to this claimant.
case SearchState::NONE:
break;
}
int err = this->check_and_log_error_("esp_ble_gattc_search_service",
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
if (err == 0) {
this->search_state_ = SearchState::CLAIMED;
}
return err;
return this->check_and_log_error_("esp_ble_gattc_search_service",
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
}
int BluedroidGattClient::read_characteristic(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
// The BTC layer copies the payload immediately, so the const_cast is safe.
return this->check_and_log_error_(
"esp_ble_gattc_write_char",
@@ -255,18 +203,12 @@ int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *da
}
int BluedroidGattClient::read_descriptor(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return this->check_and_log_error_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return this->check_and_log_error_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
@@ -274,9 +216,6 @@ int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data,
}
int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
// Local registration only; the CCCD write is the API client's responsibility.
if (enable) {
return this->check_and_log_error_("esp_ble_gattc_register_for_notify",
@@ -286,12 +225,7 @@ int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
int BluedroidGattClient::pair() {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT);
}
int BluedroidGattClient::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) {
@@ -300,18 +234,190 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
// Always set: terminates any in-flight stream on every cache config.
this->services_released_ = true;
#ifdef USE_BLE_GATT_SERVICE_TABLE
this->free_service_table_();
#endif
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// A failed clean leaves a stale database the next connection could serve
// as authoritative. A disabled stack invalidates its own cache; skip the
// meaningless call instead of warning on every OTA/ble.disable teardown.
if (esp32_ble::global_ble->is_active()) {
this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_));
}
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
#endif
}
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
if (this->table_storage_ == nullptr &&
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
return {};
}
return this->table_view_();
}
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->table_storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes),
this->service_total_,
this->table_char_total_,
this->table_desc_total_};
}
void BluedroidGattClient::free_service_table_() {
if (this->table_storage_ == nullptr) {
return;
}
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->table_storage_, 0);
this->table_storage_ = nullptr;
this->table_char_total_ = 0;
this->table_desc_total_ = 0;
}
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
// Shared enumeration for both table-build passes: an identical walk order
// is what lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK ||
svc_count == 0) {
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle,
svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) {
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
if (status != ESP_GATT_OK || char_count == 0) {
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidGattClient::build_service_table_() {
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->table_storage_ = allocator.allocate(total_bytes);
if (this->table_storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_,
static_cast<unsigned>(total_bytes));
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
auto *descriptors =
reinterpret_cast<ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_database_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
this->free_service_table_();
return false;
}
this->table_char_total_ = char_total;
this->table_desc_total_ = desc_total;
return true;
}
#endif // USE_BLE_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
@@ -319,19 +425,21 @@ bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
}
void BluedroidGattClient::set_idle_() {
this->set_state(ClientState::IDLE);
this->set_state_(ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
this->search_state_ = SearchState::NONE;
this->search_status_ = 0;
}
void BluedroidGattClient::set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(ClientState::DISCONNECTING);
this->set_state_(ClientState::DISCONNECTING);
// The loop may be disabled while idle; the safety timeout needs it.
this->enable_loop();
}
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
this->listener_->on_connection_state(connected, this->mtu_, error);
}
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
@@ -357,15 +465,9 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
// ---- service streaming ----
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
void BluedroidGattClient::handle_search_cmpl_() {
// Step down from the fast discovery params.
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
if (status != ESP_GATT_OK) {
// A failed discovery reads as a clean zero from the count calls below;
// honoring the event status stops it becoming an authoritative empty
// list.
return status;
}
uint16_t primary = 0;
uint16_t secondary = 0;
auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE,
@@ -373,50 +475,34 @@ int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE,
0x0001, 0xFFFF, 0, &secondary);
if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) {
// A failed count must not become an authoritative empty database.
auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status);
return count_status;
// A failed count must not become an authoritative empty database - V3
// clients cache the streamed result permanently.
auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status);
this->listener_->on_service_discovery_done(status);
return;
}
this->service_total_ = primary + secondary;
return 0;
this->listener_->on_service_discovery_done(0);
}
// Reports a completed search once claimed; delivery consumes the state so
// a re-discovery issues a real search.
void BluedroidGattClient::deliver_pending_search_() {
if (this->search_state_ != SearchState::REPORT_PENDING)
return;
this->search_state_ = SearchState::NONE;
this->listener_->on_service_discovery_done(this->search_status_);
}
#ifdef USE_BLUETOOTH_PROXY
// The wrapper's compile-time streamer detection must keep finding this
// method; a signature drift would silently fall back to the table streamer,
// which proxy builds compile without a materializer.
static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); });
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_) {
// Released under the stream: park without services-done so a partial
// list is never cached as authoritative (the client retries after its
// GetServices timeout).
ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_);
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
if (conn.send_service_ >= this->service_total_) {
conn.proxy_->send_gatt_services_done(conn.address_);
this->release_services();
conn.send_services_done_();
return;
}
// The subscriber vanished mid-stream.
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list).
auto *api_conn = conn.proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_);
conn.park_service_stream_();
conn.send_service_ = DONE_SENDING_SERVICES;
this->release_services();
return;
}
@@ -429,21 +515,19 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
while (conn.send_service_ < this->service_total_) {
esp_gattc_service_elem_t service_result;
uint16_t svc_count = 1;
esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result,
&svc_count, conn.send_service_);
if (svc_status != ESP_GATT_OK || svc_count == 0) {
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &svc_count,
conn.send_service_) != ESP_GATT_OK ||
svc_count == 0) {
ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_,
conn.address_str_, conn.send_service_);
conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
uint16_t total_char_count = 0;
auto char_count_status =
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0, &total_char_count);
if (char_count_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status);
conn.abort_service_stream(char_count_status);
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0,
&total_char_count) != ESP_GATT_OK) {
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
@@ -469,12 +553,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
uint16_t cc = 1;
auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
service_result.end_handle, &char_result, &cc, char_offset);
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
break;
}
if (char_status != ESP_GATT_OK || cc == 0) {
// An early terminator contradicts the count from the same cache;
// never stream a silently truncated list.
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND);
return;
if (char_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
@@ -490,7 +578,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
// Abort rather than stream the characteristic descriptor-less: a
// missing CCCD in a cached database breaks notifications for good.
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status);
conn.abort_service_stream(desc_count_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
if (total_desc_count > 0) {
@@ -501,10 +589,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
uint16_t dc = 1;
auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle,
&desc_result, &dc, desc_offset);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || dc == 0) {
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND);
return;
if (desc_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
@@ -531,20 +625,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
conn.send_service_ = batch_start;
}
}
#endif // USE_BLUETOOTH_PROXY
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
// ---- events ----
void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
auto st = this->state();
auto st = this->state_();
if (st == ClientState::IDLE) {
// Late OPEN_EVT after the slot went IDLE (open-error race, or the
// teardown net gave up): close a won link, never resurrect the slot.
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status);
if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) {
// A failed close here leaks a live link nothing tracks; make it heard.
this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id));
}
// IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an
// error and the slot went IDLE; do not resurrect it.
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, param->open.status);
return;
}
if (st != ClientState::CONNECTING) {
@@ -554,49 +644,36 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
this->log_gattc_warning_("Connection open", param->open.status);
// Never established, CLOSE_EVT may not follow.
this->set_idle_();
this->listener_->on_connection_state(false, 0, param->open.status);
this->report_connection_state_(false, param->open.status);
return;
}
if (this->disconnect_pending()) {
// Open resolved with a teardown scheduled: close now (conn_id_ stays set
// so CLOSE_EVT still matches).
if (this->shim_.disconnect_pending()) {
// Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches.
this->unconditional_disconnect_();
return;
}
this->set_state(ClientState::CONNECTED);
this->set_state_(ClientState::CONNECTED);
ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_);
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
this->set_state(ClientState::ESTABLISHED);
// No discovery phase: report immediately with the default MTU. The
// cached path never waits for (or reports) the exchange - seen_mtu_
// suppresses the CFG_MTU report, matching the previous esp32 behavior.
this->set_state_(ClientState::ESTABLISHED);
// No discovery phase: report immediately; the MTU report below is
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
// matching the previous esp32 behavior).
this->seen_mtu_ = true;
this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0);
} else {
// Discovery-bound connection: start the search now so it overlaps the
// MTU exchange. On a refusal fall back to the serialized path - the
// consumer's own discover_services() call retries the real search.
if (this->check_and_log_error_("esp_ble_gattc_search_service",
esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) {
this->search_state_ = SearchState::PRESTARTED;
}
if (this->mtu_failed_ && !this->seen_mtu_) {
// Refused MTU request: report with the default so the consumer
// proceeds.
this->seen_mtu_ = true;
this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0);
this->deliver_pending_search_();
}
this->report_connection_state_(true, 0);
// Settled: only the disconnect safety net needs the loop, and
// set_disconnecting_() re-enables it.
this->disable_loop();
}
}
void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) {
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) {
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_() == ClientState::CONNECTED) {
ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_);
} else {
ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason);
}
if (this->state() == ClientState::IDLE) {
if (this->state_() == ClientState::IDLE) {
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
return;
}
@@ -608,9 +685,9 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
this->set_disconnecting_();
}
bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id)
if (event == ESP_GATTC_REG_EVT && this->shim_.app_id != param->reg.app_id)
return false;
if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_)
return false;
@@ -633,8 +710,6 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret);
// No CFG_MTU_EVT will follow; OPEN_EVT reports with the default.
this->mtu_failed_ = true;
}
break;
}
@@ -650,17 +725,13 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
if (param->cfg_mtu.status != ESP_GATT_OK) {
// Warn only; a disconnect will follow if the link is dead.
this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status);
} else {
this->mtu_ = param->cfg_mtu.mtu;
}
if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) {
// Teardown owns the link: suppress the connected report here like
// OPEN_EVT and SEARCH_CMPL do; the terminal report settles it.
if (!this->seen_mtu_) {
this->seen_mtu_ = true;
// The connected report waited for the MTU; forwarded, not stored.
this->listener_->on_connection_state(
true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0);
// The consumer requests discovery from inside that report; when the
// pre-started search already finished, complete it in the same drain.
this->deliver_pending_search_();
// The connected report waited for the MTU so HA never sees 23.
this->report_connection_state_(true, 0);
}
break;
}
@@ -677,23 +748,17 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->set_idle_();
// The one connected=false report: the wrapper frees the slot on it,
// so it must not fire before the controller finished closing.
this->listener_->on_connection_state(false, 0, param->close.reason);
this->report_connection_state_(false, param->close.reason);
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->conn_id_ != param->search_cmpl.conn_id)
return false;
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
if (this->state() == ClientState::DISCONNECTING) {
// Teardown owns the link; the result is never delivered, skip the
// work.
break;
}
this->search_status_ = this->handle_search_cmpl_(static_cast<esp_gatt_status_t>(param->search_cmpl.status));
this->search_state_ =
this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE;
this->set_state(ClientState::ESTABLISHED);
this->deliver_pending_search_();
this->set_state_(ClientState::ESTABLISHED);
this->handle_search_cmpl_();
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
this->disable_loop();
break;
}
case ESP_GATTC_READ_CHAR_EVT:
@@ -737,18 +802,13 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
return true;
}
void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
case ESP_GAP_BLE_SEC_REQ_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
// Always accept; a refused response means no AUTH_CMPL, so answer the
// pairing request with the failure.
int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp",
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true));
if (sec_err != 0) {
this->listener_->on_pairing_result(sec_err);
}
// Always accept a server-initiated security request.
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);
break;
}
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
@@ -2,8 +2,10 @@
// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection
// wrapper. Not a BLEClientBase: the tracker's promote loop owns
// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only
// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in
// the tracker-invoked connect() override.
// parks the address in DISCOVERED and the real esp_ble_gattc_open happens in
// the tracker-invoked shim connect(). The shim exists because the tracker's
// ESPBTClient::disconnect() returns void while the contract's returns int -
// one class cannot carry both.
#pragma once
@@ -11,6 +13,8 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
@@ -20,37 +24,16 @@
namespace esphome::bluetooth_connection {
#ifdef USE_BLUETOOTH_PROXY
class BluedroidGattClient;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
class BluetoothConnection;
#endif
// One class carries both halves: the tracker's ESPBTClient surface (its
// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the
// virtual connect()/disconnect()) and the neutral contract ops. The
// contract's teardown op is named gatt_disconnect() because the tracker's
// void disconnect() cannot overload with an int-returning twin.
class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component {
// The tracker-facing half: owns the ClientState the promote loop reads and
// forwards events/commands to the engine.
class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient {
public:
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
// Lifecycle of one connection attempt's service search.
enum class SearchState : uint8_t {
NONE, // no search this attempt
PRESTARTED, // issued at OPEN_EVT, no claimant yet
PRESTART_DONE, // completed with search_status_ latched, no claimant yet
CLAIMED, // in flight with a claimant (pre-started or direct)
REPORT_PENDING // completed and claimed: deliver on the next flush
};
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- esp32_ble_tracker::ESPBTClient ----
explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {}
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
@@ -60,10 +43,26 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
void schedule_disconnect() { this->want_disconnect_ = true; }
protected:
BluedroidGattClient *engine_;
};
class BluedroidGattClient final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int gatt_disconnect();
bool cancel_gatt_disconnect();
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
@@ -72,14 +71,20 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// Contract stub: the proxy streams in place; the on-demand materializer
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
// this stub gets an empty table indistinguishable from a service-less
// peer - do not ship one against this backend before the materializer.
// Materialized on demand from Bluedroid's cached database for direct
// consumers that resolve handles by UUID. The streaming consumer (the
// proxy wrapper) never calls this - it uses stream_service_batch - so the
// materializer only compiles when codegen declares a direct consumer
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
// footprint; a direct consumer's peak is bounded by its one known device.
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
#ifdef USE_BLUETOOTH_PROXY
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
@@ -89,52 +94,71 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
protected:
friend class BluedroidTrackerShim;
esp32_ble_tracker::ClientState state_() const { return this->shim_.state(); }
void set_state_(esp32_ble_tracker::ClientState st) { this->shim_.set_state(st); }
bool check_addr_(const esp_bd_addr_t &addr) const;
void tracker_connect_();
bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param);
void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
int handle_search_cmpl_(esp_gatt_status_t status);
void deliver_pending_search_();
void handle_search_cmpl_();
void unconditional_disconnect_();
void set_idle_();
void set_disconnecting_();
void report_connection_state_(bool connected, int error);
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
int check_and_log_error_(const char *operation, esp_err_t err);
void log_gattc_warning_(const char *operation, int code);
#ifdef USE_BLE_GATT_SERVICE_TABLE
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
bool build_service_table_();
void free_service_table_();
ble_device_base::GattServiceTable table_view_() const;
#endif
// Group 1: pointers / composed objects
BluedroidTrackerShim shim_{this};
ble_device_base::GattClientListener *listener_{nullptr};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// One exact-size block carved into the table's three arrays; owned here,
// freed by release_services(). Null when no table is materialized. The
// GattServiceTable view is rebuilt from this pointer and the counts on
// each (cold) get_service_table() call instead of being cached.
uint8_t *table_storage_{nullptr};
#endif
// Group 2: 4-byte types
int gattc_if_{ESP_GATT_IF_NONE};
uint32_t disconnecting_started_{0};
// Group 3: arrays
esp_bd_addr_t remote_bda_{};
// Group 4: 2-byte types
uint16_t conn_id_{UNSET_CONN_ID};
uint16_t conn_id_{0xFFFF};
uint16_t mtu_{23};
uint16_t service_total_{0};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// Filled element counts of the materialized table (0 when none).
uint16_t table_char_total_{0};
uint16_t table_desc_total_{0};
#endif
// Group 5: 1-byte types
esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
uint8_t remote_addr_type_{0};
esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE};
uint8_t connection_index_{0};
// Terminates an in-flight stream (never send a partial list as authoritative)
// and marks a cleaned cache unsafe to walk (Bluedroid asserts).
bool services_released_ : 1 {false};
uint8_t connection_index_;
// Set only when release_services() cleans the stack's GATT cache, which no
// walk may then touch (Bluedroid asserts rather than erroring).
bool services_released_{false};
// The connected report waits for the MTU exchange; OPEN_EVT alone would
// hand HA the default 23.
bool seen_mtu_ : 1 {false};
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
bool mtu_failed_ : 1 {false};
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
// completes from it. Reset by set_idle_().
static_assert(static_cast<uint8_t>(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow");
SearchState search_state_ : 4 {SearchState::NONE};
// esp_gatt_status_t of the completed search, held until claimed.
uint8_t search_status_{0};
bool seen_mtu_{false};
};
} // namespace esphome::bluetooth_connection
@@ -28,8 +28,7 @@ class StubGattBackend {
public:
void set_listener(ble_device_base::GattClientListener *listener) {}
int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
bool cancel_gatt_disconnect() { return false; }
int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
@@ -1,7 +1,7 @@
// The proxy's per-slot connection wrapper, shared by every platform.
#include "bluetooth_connection_hub.h"
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -21,16 +21,16 @@ void BluetoothConnection::set_address(uint64_t address) {
this->address_str_[0] = '\0';
return;
}
uint8_t mac[MAC_ADDRESS_SIZE];
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BluetoothConnection::initiate_connection(uint8_t address_type) {
// No connect timeout here: the API client's own timeout or the api-gone
// sweep drives disconnect().
void BluetoothConnection::start_connect_() {
// No connect timeout here: the API client's own timeout or
// the api-gone sweep drives disconnect().
this->state_ = ClientState::CONNECTING;
int err = this->backend_->connect(this->address_, address_type);
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err);
this->reset_connection_(err);
@@ -38,21 +38,40 @@ void BluetoothConnection::initiate_connection(uint8_t address_type) {
}
void BluetoothConnection::disconnect() {
// Idempotent: the proxy's teardown loop calls this every 100 ms while the
// API subscriber is gone, and a repeat call reaching the backend would
// re-arm its teardown timer so the safety timeout never fires.
// Idempotent: the proxy's teardown loop calls this
// every 100 ms while the API subscriber is gone, and a repeat call must not
// reach the backend (whose busy error would free the slot mid-teardown).
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
return;
}
int err = this->backend_->gatt_disconnect();
if (err != 0) {
// Nonzero means nothing to tear down (both backends): free the slot.
// Accepted teardowns always reach a terminal report.
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err);
int err = this->backend_->disconnect();
if (err == GATT_NOT_CONNECTED) {
// Backend already idle: free the slot so the client is not stuck.
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_);
this->reset_connection_(err);
return;
}
if (err != 0) {
// Transient refusal: stay DISCONNECTING and let the safety timeout
// arbitrate rather than freeing a slot whose teardown is unresolved.
// Latch the refusal unless a GATT cause is already recorded (first wins).
ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
if (this->pending_error_ == 0) {
this->pending_error_ = err;
}
}
this->state_ = ClientState::DISCONNECTING;
this->disconnecting_started_ = millis();
}
void BluetoothConnection::check_disconnect_timeout_() {
// Safety net: if the backend's disconnect completion is lost (or a refusal
// left the teardown unresolved), force the slot free instead of leaking it.
// The caller already gates on DISCONNECTING.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
this->reset_connection_(GATT_NOT_CONNECTED);
}
}
void BluetoothConnection::on_pairing_result(int status) {
@@ -83,18 +102,26 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
if (connected && this->address_ == 0) {
// Late completion for a slot that was already freed: nothing to report,
// and the api-gone sweep or a new reservation owns the slot now.
// Return ignored: nonzero just means the backend was already idle, and
// re-arming a freed slot could clobber a new reservation.
this->backend_->gatt_disconnect();
int err = this->backend_->disconnect();
if (err != 0 && err != GATT_NOT_CONNECTED) {
// Log only: re-arming a freed slot could clobber a new reservation.
ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err);
}
return;
}
if (connected && this->state_ == ClientState::DISCONNECTING) {
// The link came up after a disconnect request won the race; finish the
// teardown instead of reporting a connection the client no longer wants.
int err = this->backend_->gatt_disconnect();
if (err != 0) {
int err = this->backend_->disconnect();
// Fresh teardown attempt: give it the full safety window.
this->disconnecting_started_ = millis();
if (err == GATT_NOT_CONNECTED) {
// Nothing left to tear down after all.
this->reset_connection_(err);
} else if (err != 0) {
// Transient refusal while the link is up: keep DISCONNECTING and let
// the safety timeout arbitrate (same policy as disconnect()).
ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
}
return;
}
@@ -103,10 +130,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
// The API client has the services cached; never discover them. No
// discovery phase needs the fast interval, so settle straight into the
// shared steady-state parameters. Both backends already open cached
// connections with these values (esp32 prefer-params, rp2 initiating
// params), so this request is normally redundant - kept as a backstop
// in case the initial parameters were negotiated away.
// shared steady-state parameters. On esp32 the backend already set the
// same values as prefer-params before opening, so this request is
// usually redundant there - kept because rp2 has no prefer-params and
// the explicit update is its only path to the steady-state interval.
this->state_ = ClientState::ESTABLISHED;
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
@@ -121,13 +148,14 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
return;
}
// V3_WITHOUT_CACHE: discover services first — the connected response is
// sent when discovery completes (MTU + services before the response).
// sent when discovery completes, mirroring the esp32 flow (MTU + services
// before the response).
this->state_ = ClientState::CONNECTED;
int err = this->backend_->discover_services();
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err);
// Latch the real cause for the disconnect report.
this->latch_pending_error_(err);
this->pending_error_ = err;
this->disconnect();
}
return;
@@ -146,7 +174,7 @@ void BluetoothConnection::on_service_discovery_done(int error) {
ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error);
// Carry the GATT error into the disconnection report so the client sees
// the real cause instead of a generic HCI reason.
this->latch_pending_error_(error);
this->pending_error_ = error;
this->disconnect();
return;
}
@@ -299,39 +327,25 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval,
// ---- Service streaming ----
void BluetoothConnection::send_services_done_() {
if (this->proxy_->send_gatt_services_done(this->address_)) {
// Sent, or subscriber gone (park silently; its timeout arbitrates).
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (this->send_service_ != SERVICES_DONE_PENDING) {
// Warn on the transition only; retries stay silent.
ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_);
this->services_done_retries_ = 0;
this->send_service_ = SERVICES_DONE_PENDING;
} else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
// Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates.
ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
}
}
void BluetoothConnection::send_service_for_discovery_() {
auto table = this->backend_->get_service_table();
if (this->send_service_ >= table.service_count) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->backend_->release_services();
this->send_services_done_();
return;
}
// The subscriber vanished mid-stream; the api-gone sweep tears the
// connection down anyway.
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (esp32 parity — a resubscribing client gets
// silence and its 30 s timeout, never an authoritative partial list) and
// free the table; the api-gone sweep tears the connection down anyway.
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_,
this->address_str_);
this->park_service_stream_();
this->send_service_ = DONE_SENDING_SERVICES;
this->backend_->release_services();
return;
}
@@ -369,7 +383,8 @@ void BluetoothConnection::send_service_for_discovery_() {
if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) {
ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (char_count > 0) {
@@ -385,7 +400,8 @@ void BluetoothConnection::send_service_for_discovery_() {
if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) {
ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (desc_count == 0) {
@@ -420,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() {
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -10,7 +10,7 @@
// The wrapper exists to serve the proxy's API surface; direct consumers
// drive the backend themselves, so backend-only builds compile this header
// empty.
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "bluetooth_connection_gatt_backend.h"
@@ -41,28 +41,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
conn_err_t notify_characteristic(uint16_t handle, bool enable);
conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
/// Streamer abort: latch the GATT cause, park the cursor, tear down.
void abort_service_stream(conn_err_t err) {
this->latch_pending_error_(err);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
/// Start connecting: record the API address type (BLE_ADDR_TYPE_* code
/// space) and open the connection through the backend. Failures report
/// through the same reset path a failed open takes on esp32.
void initiate_connection(uint8_t address_type) {
this->remote_addr_type_ = address_type;
this->start_connect_();
}
/// Start connecting with the API address type (BLE_ADDR_TYPE_* code
/// space). Failures report through the same reset path a failed open
/// takes.
void initiate_connection(uint8_t address_type);
void disconnect();
/// A connect request racing a scheduled teardown: true when the backend
/// had not started closing - the in-flight open resumes and reports
/// connected. False once the teardown owns the link.
bool cancel_teardown() {
if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) {
this->state_ = ClientState::CONNECTING;
return true;
}
return false;
}
bool is_paired() const { return this->paired_; }
void set_unpaired() { this->paired_ = false; }
conn_err_t pair() { return this->backend_->pair(); }
@@ -77,9 +63,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
void set_connection_type(ConnectionType ct) {
this->connection_type_ = ct;
// Both backends branch on the type before connecting (bluedroid picks
// prefer-params and the with-cache report at OPEN_EVT; rp2 picks the
// initiating parameters), so this must be set before the connect starts.
// The bluedroid backend branches on the type itself (prefer-params and
// the with-cache report at OPEN_EVT); the others ignore it.
this->backend_->set_connection_type(ct);
}
// Latched at discovery completion rather than read from the backend table:
@@ -88,12 +73,18 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
// an authoritative empty database).
bool has_gatt_services() const { return this->services_discovered_; }
/// Stream any pending service-discovery batch (proxy loop; the backend
/// owns the disconnect safety timer).
/// Stream any pending service-discovery batch and police the disconnect
/// safety timeout. Called from the proxy's loop — the wrapper has no
/// Component loop of its own.
void process_pending_services() {
if (this->send_service_ >= 0) {
this->stream_pending_(this->backend_);
}
// Inline state gate: this runs per loop iteration for every slot, and the
// 10 s safety net only matters while DISCONNECTING.
if (this->state_ == ClientState::DISCONNECTING) {
this->check_disconnect_timeout_();
}
}
// ---- backend event listener (called directly by the backend, main loop) ----
@@ -110,12 +101,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
// The Bluedroid backend streams services in place from its stack cache.
friend class BluedroidGattClient;
/// First cause wins: a later, less specific error must not overwrite it.
void latch_pending_error_(conn_err_t err) {
if (this->pending_error_ == 0) {
this->pending_error_ = err;
}
}
void start_connect_();
// A backend providing its own streamer (see the contract doc) builds the
// response in place from its stack cache; the rest use the table streamer.
// Template so the discarded branch is not odr-checked against backends
@@ -127,23 +113,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
this->send_service_for_discovery_();
}
}
/// Park the stream without services-done and free any held table: an
/// interrupted stream must never be declared complete (the client's
/// timeout arbitrates), and an owed done is dropped with it.
void park_service_stream_() {
if (this->send_service_ >= 0) {
this->backend_->release_services();
this->send_service_ = DONE_SENDING_SERVICES;
} else if (this->send_service_ == SERVICES_DONE_PENDING) {
this->send_service_ = DONE_SENDING_SERVICES;
}
}
void send_service_for_discovery_();
/// Send services-done and settle the cursor: DONE when it lands (or no
/// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain
/// retries). Callers release the table first; the message needs only the
/// address.
void send_services_done_();
void check_disconnect_timeout_();
void reset_connection_(conn_err_t reason);
conn_err_t check_connected_op_(const char *action, const char *type) const;
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
@@ -155,30 +126,25 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
// Group 2: 2-byte types
int16_t send_service_{INIT_SENDING_SERVICES};
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
uint16_t mtu_{23};
// Group 3: 8-byte and 4-byte types
uint64_t address_{0};
uint32_t disconnecting_started_{0};
conn_err_t pending_error_{0};
// Group 4: Arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48.
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2),
"connection_type_ bitfield too narrow");
// Ordered so neither byte's fields straddle a storage unit: 3+5 and
// 4+2+1+1 fill the two tail bytes exactly.
ClientState state_ : 3 {ClientState::IDLE};
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
uint8_t services_done_retries_ : 5 {0};
uint8_t connection_index_ : 4 {0};
ConnectionType connection_type_ : 2 {ConnectionType::V1};
bool paired_ : 1 {false};
bool services_discovered_ : 1 {false};
// Group 5: 1-byte types
ClientState state_{ClientState::IDLE};
bool paired_{false};
ConnectionType connection_type_{ConnectionType::V1};
uint8_t remote_addr_type_{0};
uint8_t connection_index_{0};
bool services_discovered_{false};
};
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -5,7 +5,6 @@
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <BluetoothLock.h>
@@ -26,13 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
// and keeps the scan inhibited, so the engine cancels after 20 s. The
// disconnect timeout mirrors the esp32 CLOSE_EVT safety net.
static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000;
// Budget after a cancel is in flight: its completion normally lands within
// tens of ms, and while the engine waits it pins the stack-wide connect slot,
// so a lost completion must cost seconds, not another full connect budget.
static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000;
// Pending engines re-attempt gap_connect on this cadence instead of every
// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock.
static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50;
// Can-send windows normally open within a connection interval (tens of ms).
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
@@ -61,7 +53,6 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {};
uint8_t RP2GattClient::instance_count = 0;
btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {};
btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {};
RP2GattClient *RP2GattClient::connect_owner = nullptr;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) {
@@ -92,7 +83,6 @@ void RP2GattClient::setup() {
// One locked section: the slot store lands before the count bump, and a
// live HCI handler (N > 1 builds) cannot read a half-written registry.
BluetoothLock lock;
this->engine_index_ = instance_count;
instances[instance_count] = this;
instance_count++;
// One HCI event handler for all engine instances (BTstack supports
@@ -105,24 +95,9 @@ void RP2GattClient::setup() {
}
}
#ifdef USE_OTA_STATE_LISTENER
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
this->disable_loop();
}
#ifdef USE_OTA_STATE_LISTENER
void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
// esp32 parity (its tracker disconnects every client at OTA start): free
// the shared radio for the transfer. No restore needed; the client
// reconnects, and on success the device reboots anyway.
if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) {
this->gatt_disconnect();
}
}
#endif
float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); }
@@ -148,56 +123,34 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) {
break;
}
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
bd_addr_t peer;
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
// Route by ownership, not address: gap_connect refuses a new
// create-connection until the previous completion is processed, so the
// event belongs to the owner by construction. Cancel completions carry
// a zeroed peer address on this controller, so an address match would
// drop them and pin the owner until its backstop.
RP2GattClient *inst = connect_owner;
static constexpr bd_addr_t ZERO_ADDR = {};
if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 &&
memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) {
// Addressed completion for a peer the owner is not connecting to: a
// success delayed past a cancel and an ownership handoff (the cancel
// idles the stack's request immediately) must not stamp the old
// procedure's link onto the new owner. Zero-address (cancel)
// completions need no such guard: BTstack only emits them while its
// request state is idle, and a new owner re-arms that state when it
// claims the token, so a stale cancel completion is swallowed by the
// stack, never re-attributed. A successful stale link still needs
// disposal (same hazard as the unowned branch below).
if (status == 0) {
gap_disconnect(con_handle);
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
// Route to the engine that is waiting for this peer.
for (uint8_t i = 0; i < instance_count; i++) {
RP2GattClient *inst = instances[i];
if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) {
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
break;
}
break;
}
connect_owner = nullptr;
if (inst == nullptr) {
if (status == 0) {
// Nobody owns this late link (the owner escalated first): tear it
// down here or the hci_connection_t leaks and the peer answers
// DISALLOWED until reboot.
gap_disconnect(con_handle);
}
break;
}
if (status == 0) {
// Stamp the handle here in the BTstack context: a disconnection
// racing the queued CONNECTED event arrives in this same context
// and must route by handle (it carries no address).
inst->con_handle_ = con_handle;
}
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
break;
}
case HCI_EVENT_DISCONNECTION_COMPLETE: {
// Routable even against a still-queued CONNECTED event: the handle is
// stamped in this context at connection-complete time.
RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet));
hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet);
RP2GattClient *inst = instance_for_con_handle(con_handle);
if (inst == nullptr && instance_count == 1) {
// The main loop may not have recorded the handle yet (the CONNECTED
// event is still queued); with a single engine the connecting
// instance is unambiguous, so route there to close the
// accept-then-drop window. With multiple engines the event has no
// address to match on, so it must be dropped instead of guessed.
RP2GattClient *candidate = instances[0];
if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) {
inst = candidate;
}
}
if (inst != nullptr) {
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
}
@@ -439,73 +392,44 @@ void RP2GattClient::loop() {
if (dropped > 0) {
// Control events must not be lost; the connection state is no longer
// trustworthy — recover with a forced teardown.
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
this->gatt_disconnect();
ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped);
this->disconnect();
}
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
if (notify_dropped > 0) {
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
}
if (this->state_ == EngineState::CONNECT_PENDING) {
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
uint32_t now = millis();
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
// Never reached the radio; nothing stack-side to cancel.
ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_);
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
} else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) {
this->connect_retry_ms_ = now;
if (int err = this->try_gap_connect_(); err != 0) {
this->fail_connection_(static_cast<uint8_t>(err));
}
}
} else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
uint32_t now = millis();
bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID &&
this->connect_cancel_attempted_;
uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS;
if (now - this->connect_started_ > budget) {
ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_);
bool link_up = this->state_ != EngineState::CONNECTING;
bool cancel_sent = false;
if (!link_up) {
BluetoothLock lock;
// Handle check under the lock: a success completion can stamp it in
// the BTstack context right up to this point, and escalating past a
// live link would orphan it (the queued CONNECTED event is dropped
// by the state guard once fail_connection_ runs).
link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID;
if (!link_up && connect_owner == this) {
// gap_connect_cancel is stack-global; only the engine whose
// create-connection is in flight may issue it. First timeout:
// cancel and give the completion a grace period. Second: the
// completion was lost, re-issue the cancel in case the procedure
// still runs (a no-op on an idle stack), then escalate.
ESP_LOGW(TAG, "Connect timeout");
if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) {
if (!this->connect_cancel_attempted_) {
this->connect_cancel_attempted_ = true;
BluetoothLock lock;
gap_connect_cancel();
cancel_sent = !this->connect_cancel_attempted_;
// The cancel produces a connection-complete event with a failure
// status, which drives the normal failure path; restart the timer
// so a lost event escalates below instead of wedging here.
this->connect_started_ = now;
} else {
// The cancel's completion never arrived: reclaim the slot and the
// scan rather than cancelling forever.
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
}
this->connect_cancel_attempted_ = true;
}
if (link_up) {
// The link is up (stamped mid-timeout or MTU exchange stalled): tear
// it down properly so the controller frees its side; the
// DISCONNECTING safety net below reclaims state if the disconnection
// event is lost. Dropping engine state without gap_disconnect would
// leak the live link and this engine's GATT slot for the rest of the
// boot.
this->gatt_disconnect();
} else if (cancel_sent) {
// The cancel produces a connection-complete event with a failure
// status, which drives the normal failure path; restart the timer so
// a lost event escalates on the short cancel budget.
this->connect_started_ = now;
} else {
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
// The link is up (MTU exchange stalled): tear it down properly so the
// controller frees its side; the DISCONNECTING safety net below
// reclaims state if the disconnection event is lost. Dropping engine
// state without gap_disconnect would leak the live link and the
// single GATT slot for the rest of the boot.
this->disconnect();
}
}
} else if (this->state_ == EngineState::DISCONNECTING) {
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
}
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
@@ -521,7 +445,7 @@ void RP2GattClient::loop() {
}
}
if (timed_out) {
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_);
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
}
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
@@ -542,7 +466,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
case RP2GattEvent::MTU_EXCHANGED:
if (this->state_ == EngineState::MTU_EXCHANGE) {
this->mtu_ = event.value;
ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
ESP_LOGD(TAG, "MTU %u", this->mtu_);
this->state_ = EngineState::READY;
// Scanning resumes and runs alongside the established connection.
this->release_scan_inhibit_();
@@ -590,7 +514,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
return;
}
if (status != 0) {
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
this->fail_connection_(status);
return;
}
@@ -614,7 +538,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
}
this->con_handle_ = con_handle;
this->state_ = EngineState::MTU_EXCHANGE;
ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle);
BluetoothLock lock;
// One wildcard listener covers notifications/indications for every
// characteristic on this connection; the CCCD writes come from the API
@@ -639,24 +563,6 @@ void RP2GattClient::release_scan_inhibit_() {
}
void RP2GattClient::fail_connection_(uint8_t reason) {
{
// Timeout escalation can fire with the completion event lost; release the
// stack-wide connect slot so pending engines can proceed. Until the old
// completion is processed, gap_connect answers any peer with DISALLOWED
// (the request-level guard in hci.c); a cancel idles that request
// immediately, and a late addressed completion from the old procedure is
// then dropped by the owner-peer cross-check in the handler.
BluetoothLock lock;
if (connect_owner == this) {
connect_owner = nullptr;
}
if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) {
// A success completion stamped the handle between the escalation
// decision and this lock: tear the link down before cleanup wipes the
// handle, or it leaks its pool block for the rest of the boot.
gap_disconnect(this->con_handle_);
}
}
this->cleanup_link_state_();
this->release_scan_inhibit_();
this->state_ = EngineState::IDLE;
@@ -670,19 +576,14 @@ void RP2GattClient::cleanup_link_state_() {
while ((stale = this->notify_queue_.pop()) != nullptr) {
this->notify_pool_.release(stale);
}
// con_handle_ may be stamped in the BTstack context before the main loop
// registers the listener, so a valid handle does not imply a registration;
// stop_listening on an unregistered entry is a benign no-op. One lock
// scope around check and reset so an IRQ stamp cannot land in between
// (unreachable today — ownership is released before cleanup — but the
// invariant lives three functions away).
{
// The wildcard listener is registered on the normal connect path right
// after con_handle_ is recorded; the cancel branch tears down before
// registering, where stop_listening on an unregistered entry is a no-op.
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
BluetoothLock lock;
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
}
this->con_handle_ = HCI_CON_HANDLE_INVALID;
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
}
this->con_handle_ = HCI_CON_HANDLE_INVALID;
this->notify_subscription_count_ = 0;
this->cancel_requested_ = false;
this->op_type_ = OpType::NONE;
@@ -694,7 +595,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
if (this->state_ == EngineState::IDLE) {
return;
}
ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
this->fail_connection_(reason);
}
@@ -752,7 +653,7 @@ int RP2GattClient::discover_services() {
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
this->arena_ = allocator.allocate(1);
if (this->arena_ == nullptr) {
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
ESP_LOGE(TAG, "Service table allocation failed");
return ble_device_base::GATT_ERR_NO_MEMORY;
}
new (this->arena_) ServiceArena();
@@ -858,8 +759,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
void RP2GattClient::finish_discovery_(int error) {
this->discovery_phase_ = DiscoveryPhase::NONE;
ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_,
error, this->service_count_, this->char_count_, this->desc_count_);
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
this->char_count_, this->desc_count_);
if (error == 0 && this->truncated_) {
// A partial table must not stream: V3 clients cache the database
// permanently, so an incomplete one would be wrong forever.
@@ -937,80 +838,30 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
this->parent_->inhibit_scan();
this->connect_cancel_attempted_ = false;
this->cancel_requested_ = false;
// Bounds the queued wait; restarted when gap_connect is accepted so the
// radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the
// sum via a disconnect request).
this->connect_started_ = millis();
if (int err = this->try_gap_connect_(); err != 0) {
this->release_scan_inhibit_();
return err;
uint8_t status;
{
BluetoothLock lock;
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL,
0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
}
if (status != 0) {
ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status);
this->release_scan_inhibit_();
return status;
}
this->state_ = EngineState::CONNECTING;
this->connect_started_ = millis();
this->enable_loop();
return 0;
}
// One outgoing LE create-connection exists stack-wide: issue it if no other
// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry.
// Returns nonzero only for hard failures (state untouched; caller cleans up).
int RP2GattClient::try_gap_connect_() {
// Unlocked peek: single core, aligned pointer; a stale value costs one loop
// pass and the locked re-check below is authoritative. Keeps the per-loop
// pending retry from taking BluetoothLock just to find the radio busy.
if (connect_owner != nullptr) {
this->state_ = EngineState::CONNECT_PENDING;
return 0;
}
uint8_t status;
{
BluetoothLock lock;
if (connect_owner != nullptr) {
status = ERROR_CODE_COMMAND_DISALLOWED;
} else {
// esp32 parity: cached connections come up at MEDIUM already (nothing
// consumes the fast interval without a discovery phase), so there is no
// post-connect update procedure to race or silently lose; sustained
// FAST intervals also starve WiFi on the shared CYW43 radio.
// Without-cache runs FAST for discovery and steps down in
// finish_discovery_.
bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE;
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW,
cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL,
cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0,
cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
if (status == 0) {
connect_owner = this;
// Still under the lock: a synthesized failure completion can fire in
// the BTstack context the instant it releases, and completion routing
// requires CONNECTING — set after the fact, the event is discarded
// and the engine burns its whole budget waiting for it.
this->state_ = EngineState::CONNECTING;
this->connect_started_ = millis();
}
}
}
if (status == 0) {
return 0;
}
if (status == ERROR_CODE_COMMAND_DISALLOWED) {
// Radio busy with another engine's connect; resolved from loop().
this->state_ = EngineState::CONNECT_PENDING;
return 0;
}
ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status);
return status;
}
int RP2GattClient::gatt_disconnect() {
int RP2GattClient::disconnect() {
switch (this->state_) {
case EngineState::IDLE:
return GATT_ERR_NOT_CONNECTED;
case EngineState::DISCONNECTING:
return 0; // already on its way down
case EngineState::CONNECT_PENDING:
// Nothing issued stack-side; the invalid handle takes the refused
// path below without touching the stack.
break;
case EngineState::CONNECTING: {
if (this->con_handle_ == HCI_CON_HANDLE_INVALID) {
// The cancel can lose the race against a successful connection
@@ -1019,18 +870,9 @@ int RP2GattClient::gatt_disconnect() {
// attempt, so a lost completion escalates on the next timeout tick.
this->cancel_requested_ = true;
this->connect_cancel_attempted_ = true;
// Grace period for the cancel completion: the client's disconnect
// often lands right at the engine's own deadline, and without the
// restart the loop timeout fires first and reports before the
// completion can finish the teardown cleanly.
this->connect_started_ = millis();
BluetoothLock lock;
// Owner: the cancel completes as a failed connection-complete. Not
// the owner (completion already resolved in the BTstack context): the
// queued event drives the same teardown, nothing to cancel.
if (connect_owner == this) {
gap_connect_cancel();
}
gap_connect_cancel();
// Completion arrives as a failed connection-complete event.
return 0;
}
break;
@@ -1038,23 +880,20 @@ int RP2GattClient::gatt_disconnect() {
default:
break;
}
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
{
BluetoothLock lock;
status = gap_disconnect(this->con_handle_);
}
if (status != 0) {
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
}
uint8_t status;
{
BluetoothLock lock;
status = gap_disconnect(this->con_handle_);
}
if (status != 0) {
// Refused (handle already gone) or never issued (CONNECT_PENDING):
// complete via the event queue so the listener cannot re-enter
// disconnect mid-call. BluetoothLock stops the IRQ producer, so this
// main-loop push is SPSC-safe.
BluetoothLock lock;
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
// Refused (handle already gone): complete via the event queue so the
// listener cannot re-enter disconnect() mid-call. BluetoothLock stops
// the IRQ producer, so this main-loop push is SPSC-safe.
ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status);
{
BluetoothLock lock;
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
}
}
this->state_ = EngineState::DISCONNECTING;
this->disconnecting_started_ = millis();
@@ -1259,7 +1098,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_
}
conn_err_t unpair_device(uint64_t address) {
uint8_t mac[MAC_ADDRESS_SIZE];
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
bool found = false;
BluetoothLock lock;
@@ -19,10 +19,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
#include <btstack.h>
#include <array>
@@ -75,13 +71,7 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
class RP2GattClient final : public Component,
public Parented<rp2040_ble::RP2040BLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
public:
void setup() override;
void loop() override;
@@ -92,10 +82,7 @@ class RP2GattClient final : public Component,
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int gatt_disconnect();
// Teardown starts inside gatt_disconnect() on this backend; nothing is
// ever scheduled, so there is nothing to cancel.
bool cancel_gatt_disconnect() { return false; }
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
@@ -105,26 +92,18 @@ class RP2GattClient final : public Component,
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
ble_device_base::GattServiceTable get_service_table();
// Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is
// reserved for the discovery phase of uncached connects.
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services();
#ifdef USE_OTA_STATE_LISTENER
// Drop the connection while an OTA runs (esp32 parity): an active link
// competes with the transfer for the shared radio.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
protected:
// Link/engine state. Discovery and GATT ops have their own cursors below —
// the link stays READY while they run.
enum class EngineState : uint8_t {
IDLE,
CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection
CONNECTING, // gap_connect issued, waiting for connection complete
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
READY, // on_connection_state(true) delivered
CONNECTING, // gap_connect issued, waiting for connection complete
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
READY, // on_connection_state(true) delivered
DISCONNECTING,
};
@@ -161,7 +140,6 @@ class RP2GattClient final : public Component,
int issue_descriptor_query_(uint16_t char_index);
void finish_discovery_(int error);
void fail_connection_(uint8_t reason);
int try_gap_connect_();
void cleanup_link_state_();
bool notify_subscribed_(uint16_t handle) const;
static void can_write_no_rsp_trampoline(void *context);
@@ -190,17 +168,13 @@ class RP2GattClient final : public Component,
// Group 3: 4-byte types
uint32_t connect_started_{0};
uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt
uint32_t disconnecting_started_{0};
uint32_t write_no_rsp_started_{0};
// Unscoped C enum, so int-sized: lives with the 4-byte members to keep the
// padding at the tail.
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
// Group 4: 2-byte types (table counters written from the handler during
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID};
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
uint16_t mtu_{23};
uint16_t op_handle_{0};
uint16_t op_len_{0};
uint16_t service_count_{0};
@@ -214,9 +188,8 @@ class RP2GattClient final : public Component,
// listener's deliveries on this list (esp32 parity for enable=false).
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
uint8_t notify_subscription_count_{0};
uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE};
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
EngineState state_{EngineState::IDLE};
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
OpType op_type_{OpType::NONE};
@@ -238,12 +211,6 @@ class RP2GattClient final : public Component,
static btstack_packet_callback_registration_t hci_event_registration;
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static btstack_packet_callback_registration_t sm_event_registration;
// The engine whose gap_connect is in flight: BTstack allows one outgoing LE
// create-connection stack-wide, and gap_connect_cancel is global, so only
// the owner may cancel. Written under BluetoothLock from the main loop,
// cleared in the BTstack context when the procedure resolves.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static RP2GattClient *connect_owner;
};
} // namespace esphome::bluetooth_connection
+34 -31
View File
@@ -7,7 +7,6 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE,
CONF_ID,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
@@ -48,13 +47,14 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
# supports active scanning — every current client (aioesphomeapi, bleak-esphome,
# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
# hub must not be admitted (it would be misdriven).
# 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).
# Coupled to bluetooth_connection: platforms with a GATT backend are also
# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and
# FILTER_SOURCE_FILES hub entry.
_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2)
# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry.
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
DEPENDENCIES = ["api"]
CODEOWNERS = ["@jesserockz", "@bdraco"]
@@ -100,7 +100,7 @@ def _esp32_config_schema() -> cv.All:
)
elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config
)
@@ -151,23 +151,23 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
@functools.cache
def _rp2_config_schema() -> cv.All:
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
GATT client backend in bluetooth_connection. Multi-slot builds replace the
prebuilt library's one-client BTstack pools via linker --wrap, owned by
rp2040_ble and requested when a second backend registers."""
GATT client backend in bluetooth_connection. The slot limit comes from the
prebuilt BTstack library (one connection today); the code is built for N."""
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
def populate_connections(config: ConfigType) -> ConfigType:
from esphome.components import rp2040_ble
# One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
)(config)
return {
**config,
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
CONF_CONNECTIONS: [
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
],
}
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
@@ -185,8 +185,8 @@ def _rp2_config_schema() -> cv.All:
min=1,
max=max_conn,
msg=f"rp2 supports at most {max_conn} connection slot(s); "
"the BTstack pool overrides in rp2040_ble are sized "
f"for {max_conn}",
"the framework's BTstack library is built with "
f"MAX_NR_GATT_CLIENTS {max_conn}",
),
),
}
@@ -203,13 +203,10 @@ def _rp2_config_schema() -> cv.All:
async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
"""One wrapper + backend pair per slot; the platform-specific backend
registration lives in bluetooth_connection.new_gatt_backend()."""
connections = config.get(CONF_CONNECTIONS, [])
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
# this define whenever a proxy is present (zero on advertisement-only
# hubs); sized here so it can never diverge from the loop below.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections))
for connection_conf in connections:
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
for connection_conf in config.get(CONF_CONNECTIONS, []):
backend = await bluetooth_connection.new_gatt_backend(
connection_conf, service_table=False
)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
@@ -266,15 +263,11 @@ def _validate_platform(config: ConfigType) -> ConfigType:
# 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.
full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)])
adv_only = ", ".join(
sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS))
)
raise cv.Invalid(
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
"active-scan-capable BLE tracker hub is available for this "
f"platform. It runs on {full} (full proxy) and {adv_only} "
"(advertisement-only)."
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
"family (advertisement-only)."
)
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
@@ -373,6 +366,10 @@ async def _to_code_esp32(config: ConfigType) -> None:
# registration into the proxy; the other hubs are polled instead.
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
# Define max connections for protobuf fixed array
connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
await _connections_to_code(var, config)
if config.get(CONF_CACHE_SERVICES):
@@ -387,6 +384,12 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
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. Zero on advertisement-only hubs.
# Sized from the instantiated connections so the define can never diverge
# from the loop below (the define sizes fixed storage in the proxy).
slots = len(config.get(CONF_CONNECTIONS, ()))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
await _connections_to_code(var, config)
@@ -40,7 +40,7 @@ static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
if (this->api_connection_ == nullptr)
return true; // Nobody subscribed: nothing owed
return false;
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
@@ -51,12 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
return this->api_connection_->send_message(resp);
}
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) {
// False only on a refused frame, so the latch arms only when a retry is owed.
this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state);
}
#else
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_polled_scanner_state_() {
// One read feeds both the frame and the change detector; the detector only
// advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
@@ -67,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() {
this->last_scan_running_ = running;
}
}
#endif // USE_BLE_SCANNER_STATE_CALLBACK
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
@@ -83,7 +78,7 @@ void BluetoothProxy::setup() {
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Only push hubs compile the slot; elsewhere loop() polls scan_running().
this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) {
static_cast<BluetoothProxy *>(self)->send_scanner_state_(state);
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
}});
#endif
}
@@ -111,7 +106,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
}
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), ble_device_base::client_state_to_string(state));
@@ -120,7 +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 // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -140,11 +135,11 @@ void BluetoothProxy::dump_config() {
// 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[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_str[18];
this->get_bluetooth_mac_address_pretty(mac_str);
const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)";
const char *scan_mode = this->configured_scan_active_ ? "active" : "passive";
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
@@ -162,7 +157,7 @@ void BluetoothProxy::dump_config() {
#endif
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused.
void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) {
@@ -195,84 +190,33 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
}
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
// Match before free entry so one address never occupies two pool slots.
PendingDisconnect *free_entry = nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto &owed = this->pending_disconnections_[i];
if (owed.matches(address)) {
owed.set(address, error);
return;
}
if (free_entry == nullptr && owed.empty()) {
free_entry = &owed;
}
}
if (free_entry != nullptr) {
free_entry->set(address, error);
return;
}
// Every entry is owed: evict the first so the newest loss is not silent too.
ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full",
(unsigned long long) this->pending_disconnections_[0].address());
this->pending_disconnections_[0].set(address, error);
}
void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
// A reconnect supersedes the owed disconnect; a late resend would shadow
// the new connection.
for (uint8_t i = 0; i < this->connection_count_; i++) {
if (this->pending_disconnections_[i].matches(address)) {
this->pending_disconnections_[i].clear();
}
}
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
if (!this->send_device_connection(connection->get_address(), false, 0, reason)) {
// The client has no other way to learn of an unsolicited disconnect;
// latch and let loop()'s paced drain deliver it. V by design: a louder
// level would ride the same congested link this reports on.
ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(),
connection->address_str());
this->latch_pending_disconnection_(connection->get_address(), reason);
}
this->send_device_connection(connection->get_address(), false, 0, reason);
connection->set_address(0);
connection->send_service_ = INIT_SENDING_SERVICES;
this->send_connections_free();
}
BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) {
// Finish the scan before reserving: a free slot earlier in the array must
// not win over a later slot that already holds the address, or one device
// ends up on two slots with a second connection attempt racing the first.
BluetoothConnection *free_slot = nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
uint64_t conn_addr = connection->get_address();
if (conn_addr == address) {
// A connect request supersedes an owed disconnect.
if (reserve) {
this->clear_pending_disconnection_(address);
}
if (conn_addr == address)
return connection;
if (reserve && conn_addr == 0) {
connection->send_service_ = INIT_SENDING_SERVICES;
connection->set_address(address);
// All connections must start at INIT
// We only set the state if we allocate the connection
// to avoid a race where multiple connection attempts
// are made.
connection->set_state(ClientState::INIT);
return connection;
}
if (free_slot == nullptr && conn_addr == 0)
free_slot = connection;
}
if (!reserve || free_slot == nullptr)
return nullptr;
this->clear_pending_disconnection_(address);
free_slot->send_service_ = INIT_SENDING_SERVICES;
free_slot->set_address(address);
// All connections must start at INIT
// We only set the state if we allocate the connection
// to avoid a race where multiple connection attempts
// are made.
free_slot->set_state(ClientState::INIT);
return free_slot;
return nullptr;
}
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
@@ -296,10 +240,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_device_connection(msg.address, true);
this->send_connections_free();
return;
} else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) {
ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect",
connection->get_connection_index(), connection->address_str());
return;
} else if (connection->state() != ClientState::INIT) {
// Covers CONNECTING too: a repeat request during a connect attempt is
// ignored the same way.
@@ -438,30 +378,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
}
if (!connection->has_gatt_services()) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
// Through the retrying sender: a drop must not leave discovery hanging.
// Re-entry does not depend on the cursor - this branch is gated on
// has_gatt_services() alone, so no restore is needed.
connection->send_services_done_();
return;
}
if (connection->send_service_ > 0) {
// A request mid-stream restarts from the top so the requester always
// gets the full list. No duplicate risk: the client accumulates batches
// per request, and a same-session re-request only happens after the
// previous request timed out and discarded its partial list.
ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(),
connection->address_str());
connection->send_service_ = 0;
return;
}
if (connection->send_service_ == SERVICES_DONE_PENDING) {
// A new request supersedes an owed done: the client accumulates batches
// per request, so its fresh, empty accumulator plus a bare done would
// cache as an empty database. The table is freed; the client's timeout
// arbitrates.
ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry",
connection->get_connection_index(), connection->address_str());
connection->send_service_ = DONE_SENDING_SERVICES;
this->send_gatt_services_done(msg.address);
return;
}
if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet
@@ -509,7 +426,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
@@ -552,7 +469,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
#endif // USE_ESP32
void BluetoothProxy::loop() {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Stream pending service-discovery batches every iteration; the streamer
// handles a vanished API connection itself.
for (uint8_t i = 0; i < this->connection_count_; i++) {
@@ -566,17 +483,8 @@ void BluetoothProxy::loop() {
return;
this->last_advertisement_flush_time_ = now;
if (this->connections_free_pending_ && this->api_connection_ != nullptr) {
// Resend a dropped slot-state update, paced by the 100 ms gate so the
// retry does not hammer the congestion it exists to survive; the
// advertisement-only arm answers DISCONNECT requests with this message
// too, so the drain compiles on every proxy build.
this->connections_free_pending_ = false;
this->send_connections_free(this->api_connection_);
}
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// The API subscriber is gone: tear down any connections it left behind
// (disconnect() on an already-disconnecting slot is a no-op).
for (uint8_t i = 0; i < this->connection_count_; i++) {
@@ -589,27 +497,7 @@ void BluetoothProxy::loop() {
return;
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
// Paced retries of owed per-slot notifications; subscriber swaps clear
// stale latches before this runs.
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->send_service_ == SERVICES_DONE_PENDING) {
connection->send_services_done_();
}
auto &owed = this->pending_disconnections_[i];
if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) {
owed.clear();
}
}
#endif
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Resend a dropped scanner-state push (see scanner_state_pending_).
if (this->scanner_state_pending_) {
this->send_scanner_state_(this->hub_->get_scanner_state());
}
#else
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
// This hub doesn't push scanner-state transitions; poll and report on
// change. A hub gaining push emits the define and drops this poll.
if (this->hub_->scan_running() != this->last_scan_running_) {
@@ -620,7 +508,7 @@ void BluetoothProxy::loop() {
this->flush_pending_advertisements_();
}
#ifndef BLUETOOTH_CONNECTION_HAS_GATT
#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY
// Advertisement-only proxy. GATT client connections are excluded at compile
// time (no connection backend on this platform, or active: false), so every
@@ -692,38 +580,25 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
#endif // !BLUETOOTH_CONNECTION_HAS_GATT
#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (api_connection != this->api_connection_) {
if (this->api_connection_ != nullptr) {
// A previous subscriber still holds the slot. This is almost always a
// stale connection from a client that dropped without a clean disconnect
// and has not yet hit the keepalive timeout; rejecting the new
// subscriber would silently starve it of advertisements until it
// reconnects, so the newest subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
}
// Stale retry latches belong to the previous subscriber's session; a
// re-subscribe by the current one keeps what it is still owed.
this->connections_free_pending_ = false;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
for (uint8_t i = 0; i < this->connection_count_; i++) {
// Neither a partial stream's tail nor an owed done belongs to the new
// session; silence (the client's timeout) arbitrates.
this->connections_[i]->park_service_stream_();
}
this->pending_disconnections_.fill({});
#endif
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
// A previous subscriber still holds the slot. This is almost always a stale
// connection from a client that dropped without a clean disconnect and has
// not yet hit the keepalive timeout; rejecting the new subscriber would
// silently starve it of advertisements until it reconnects, so the newest
// subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
}
this->api_connection_ = api_connection;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// get_scanner_state() is part of the push-hub surface (see BLEHubContract).
this->send_scanner_state_(this->hub_->get_scanner_state());
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
#else
this->send_polled_scanner_state_();
#endif
@@ -735,12 +610,18 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
this->connections_free_pending_ = false;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
this->scanner_state_pending_ = false;
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
call.address = address;
call.connected = connected;
call.mtu = mtu;
call.error = error;
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_connections_free() {
if (this->api_connection_ != nullptr) {
this->send_connections_free(this->api_connection_);
@@ -748,32 +629,15 @@ void BluetoothProxy::send_connections_free() {
}
void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) {
// Latch only for the current subscriber: loop() resends to api_connection_.
if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) {
// V like the api layer's own buffer-full log: a D would ride the same
// full connection.
ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full");
this->connections_free_pending_ = true;
}
api_connection->send_message(this->connections_free_response_);
}
bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
if (this->api_connection_ == nullptr)
return true; // Nobody subscribed: nothing owed
api::BluetoothDeviceConnectionResponse call;
call.address = address;
call.connected = connected;
call.mtu = mtu;
call.error = error;
return this->api_connection_->send_message(call);
}
bool BluetoothProxy::send_gatt_services_done(uint64_t address) {
if (this->api_connection_ == nullptr)
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
return;
api::BluetoothGATTGetServicesDoneResponse call;
call.address = address;
return this->api_connection_->send_message(call);
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
@@ -10,7 +10,6 @@
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
@@ -25,11 +24,9 @@ namespace esphome::bluetooth_proxy {
using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t;
using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::DONE_SENDING_SERVICES;
using bluetooth_connection::INIT_SENDING_SERVICES;
using bluetooth_connection::SERVICES_DONE_PENDING;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
#endif
@@ -60,45 +57,8 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
/// One owed freed-slot connected=false notification in a single word: the
/// 48-bit address in the low bits, the sign-extending 16-bit reason on top.
/// Every reason that reaches the pool (esp_gatt_status_t,
/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t.
class PendingDisconnect {
public:
constexpr void set(uint64_t address, conn_err_t error) {
// Mask: the address originates from the client, and a stray high bit
// must not corrupt the reason.
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
}
constexpr void clear() { this->word_ = 0; }
// Whole-word test: set() is only ever given a live (nonzero) address.
constexpr bool empty() const { return this->word_ == 0; }
// Masked like set(), so a stray high bit cannot defeat the pool lookups.
constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); }
constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; }
constexpr conn_err_t error() const { return static_cast<int16_t>(this->word_ >> 48); }
private:
static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL;
uint64_t word_{0};
};
// Pin the packing at compile time: mask and sign round-trip for every
// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit).
constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
PendingDisconnect p;
p.set(address, error);
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
}
static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
static_assert(PendingDisconnect{}.empty());
#endif
class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection;
#endif
@@ -109,9 +69,9 @@ class BluetoothProxy final : public Component {
void setup() override;
void loop() override;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifndef USE_ESP32
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
@@ -137,14 +97,10 @@ class BluetoothProxy final : public Component {
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
}
/// False only when a subscriber refused the frame; true = delivered or
/// nobody subscribed. Request-answer callers ignore the result (client
/// timeouts cover those); only reset_connection_slot_ latches for retry.
bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
/// Same convention as send_device_connection: false only on a refused frame.
bool send_gatt_services_done(uint64_t address);
void send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
@@ -202,8 +158,8 @@ class BluetoothProxy final : public Component {
return flags;
}
void get_bluetooth_mac_address_pretty(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> output) {
uint8_t mac[MAC_ADDRESS_SIZE] = {};
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
uint8_t mac[6] = {};
this->hub_->get_adapter_mac(mac);
// Unavailable -> empty string: some hubs (rp2040's BTstack) only learn
// the address once the link layer is up, and report all-zero until then.
@@ -216,9 +172,7 @@ class BluetoothProxy final : public Component {
protected:
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void send_scanner_state_(ble_device_base::ScannerState state);
#else
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
void send_polled_scanner_state_();
#endif
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
@@ -235,7 +189,7 @@ class BluetoothProxy final : public Component {
}
void log_advertisement_flush_();
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message);
@@ -243,7 +197,7 @@ class BluetoothProxy final : public Component {
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);
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Keep the pre-allocated connections-free message in step when a
/// connection slot changes address (0 = free). Called from the connection
/// classes' set_address().
@@ -277,48 +231,32 @@ class BluetoothProxy final : public Component {
/// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service
/// discovery and retry, rather than being told a partial list is complete.
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
/// Drop any owed freed-slot notification for this address (client reconnected).
void clear_pending_disconnection_(uint64_t address);
/// Pool a refused freed-slot notification for the paced drain.
void latch_pending_disconnection_(uint64_t address, conn_err_t error);
#endif
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
// Address-keyed pool of owed freed-slot notifications; loop() resends.
// Proxy-only state, kept off BluetoothConnection; entries are not tied to
// slot indices.
std::array<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
#endif
ble_device_base::BLEHub *hub_{nullptr};
// Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below
// start on an even word, closing two alignment holes.
uint32_t last_advertisement_flush_time_{0};
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
// Group 3: 4-byte types
uint32_t last_advertisement_flush_time_{0};
// Pre-allocated response message - always ready to send
api::BluetoothConnectionsFreeResponse connections_free_response_;
// Group 4: 1-byte types grouped together
bool active_;
// A dropped send (full TCP buffer) would leave the API client with a stale
// slot state forever; the cached response is current by construction, so
// retrying it from loop() is an idempotent resync.
bool connections_free_pending_{false};
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// A dropped push (full TX buffer) is re-queried from the hub and resent
// from loop(); the hub's current state is idempotent by construction.
bool scanner_state_pending_{false};
#else
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
@@ -12,7 +12,6 @@ from esphome.components.packages import validate_source_shorthand
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
import esphome.final_validate as fv
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.yaml_util import dump
dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import")
@@ -110,7 +109,6 @@ def import_config(
if git_file.query and "full_config" in git_file.query:
url = git_file.raw_url
try:
ensure_happy_eyeballs()
req = requests.get(url, timeout=30)
req.raise_for_status()
except requests.exceptions.RequestException as e:
+2 -12
View File
@@ -3286,19 +3286,9 @@ def copy_files():
if str(path).startswith("http"):
import requests
from esphome.happy_eyeballs import ensure_happy_eyeballs
ensure_happy_eyeballs()
try:
req = requests.get(path, timeout=30)
req.raise_for_status()
except requests.exceptions.RequestException as e:
raise EsphomeError(
f"Could not download extra build file {path}: {e}"
) from e
CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
CORE.relative_build_path(name).write_bytes(req.content)
content = requests.get(path, timeout=30).content
CORE.relative_build_path(name).write_bytes(content)
else:
copy_file_if_changed(path, CORE.relative_build_path(name))
+5 -5
View File
@@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat
}
#endif
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const {
// The running stack owns the address (on hosted controllers it lives in
// the remote chip's efuse); null before init becomes all-zero.
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
memcpy(out, mac, MAC_ADDRESS_SIZE);
memcpy(out, mac, 6);
} else {
memset(out, 0, MAC_ADDRESS_SIZE);
memset(out, 0, 6);
}
}
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
void ESP32BLE::dump_config() {
uint8_t mac_address[MAC_ADDRESS_SIZE];
uint8_t mac_address[6];
this->get_mac_msb_first(mac_address);
if (mac_address_is_valid(mac_address)) {
const char *io_capability_s;
@@ -713,7 +713,7 @@ void ESP32BLE::dump_config() {
break;
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
format_mac_addr_upper(mac_address, mac_s);
ESP_LOGCONFIG(TAG,
"BLE:\n"
+1 -1
View File
@@ -109,7 +109,7 @@ class ESP32BLE final : public Component {
void loop() override;
void dump_config() override;
/// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up.
void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
void get_mac_msb_first(uint8_t out[6]) const;
float get_setup_priority() const override;
void set_name(const char *name) { this->name_ = name; }
@@ -128,7 +128,9 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
# window/interval pairs that collapse to the same 0.625 ms unit count.
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", supports_active=True
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
# here for the components that import them from this module.
@@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component,
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
/* scan_mode_switch = */ false};
}
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); }
void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() { return this->scan_active_; }
// The mode is driven through this tracker's own API (see get_capabilities);
+4 -17
View File
@@ -132,7 +132,6 @@ ETHERNET_TYPES = {
"W6300": EthernetType.ETHERNET_TYPE_W6300,
"GENERIC": EthernetType.ETHERNET_TYPE_GENERIC,
"YT8531": EthernetType.ETHERNET_TYPE_YT8531,
"CH390": EthernetType.ETHERNET_TYPE_CH390,
}
# PHY types that need compile-time defines for conditional compilation
@@ -154,7 +153,6 @@ _PHY_TYPE_TO_DEFINE = {
"W6300": "USE_ETHERNET_W6300",
"GENERIC": "USE_ETHERNET_GENERIC",
"YT8531": "USE_ETHERNET_YT8531",
"CH390": "USE_ETHERNET_CH390",
}
@@ -178,14 +176,13 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = {
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"),
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
"CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"),
}
# These types are always external IDF components (never built-in to ESP-IDF)
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"}
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"}
# ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver)
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"}
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"}
# RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole
# RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the
# comment above is about ESP-IDF driver coverage, not the RP2 platform.
@@ -483,12 +480,6 @@ SPI_SCHEMA = _spi_schema()
# of spec for it and makes the driver's CS hold time helper compute no hold
SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V,
# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not.
# CH390 datasheet v1.8, tables 9-4 and 9-5:
# https://www.wch-ic.com/downloads/CH390DS1_PDF.html
SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6))
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
@@ -503,7 +494,6 @@ CONFIG_SCHEMA = cv.All(
"W5500": SPI_SCHEMA,
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
"DM9051": SPI_SCHEMA,
"CH390": SPI_SCHEMA_CH390,
"ENC28J60": SPI_SCHEMA_ENC28J60,
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
@@ -639,11 +629,8 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True)
# CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0
# Types that are never built into IDF ship no Kconfig option at all
if (
idf_version() < cv.Version(6, 0, 0)
and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS
):
# ENC28J60 was never built-in to IDF, so it has no Kconfig option
if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60":
add_idf_sdkconfig_option(
f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True
)
@@ -88,7 +88,6 @@ enum EthernetType : uint8_t {
ETHERNET_TYPE_W6300,
ETHERNET_TYPE_GENERIC,
ETHERNET_TYPE_YT8531,
ETHERNET_TYPE_CH390,
};
struct ManualIP {
@@ -50,12 +50,6 @@
#include "esp_eth_enc28j60.h"
#endif
// CH390 headers exist on all IDF versions (always an external component)
#ifdef USE_ETHERNET_CH390
#include "esp_eth_mac_ch390.h"
#include "esp_eth_phy_ch390.h"
#endif
#ifdef USE_ETHERNET_SPI
#include <driver/gpio.h>
#include <driver/spi_master.h>
@@ -221,8 +215,6 @@ void EthernetComponent::ethernet_lazy_init_() {
eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg);
#elif defined(USE_ETHERNET_ENC28J60)
eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg);
#elif defined(USE_ETHERNET_CH390)
eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg);
#endif
#if defined(USE_ETHERNET_W5500)
@@ -244,11 +236,6 @@ void EthernetComponent::ethernet_lazy_init_() {
// time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID")
enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000);
enc28j60_config.int_gpio_num = this->interrupt_pin_;
#elif defined(USE_ETHERNET_CH390)
ch390_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
ch390_config.poll_period_ms = this->polling_interval_;
#endif
#endif
phy_config.phy_addr = this->phy_addr_spi_;
@@ -373,12 +360,6 @@ void EthernetComponent::ethernet_lazy_init_() {
this->phy_ = esp_eth_phy_new_enc28j60(&phy_config);
break;
}
#elif defined(USE_ETHERNET_CH390)
case ETHERNET_TYPE_CH390: {
mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config);
this->phy_ = esp_eth_phy_new_ch390(&phy_config);
break;
}
#endif
#endif
default: {
@@ -538,10 +519,6 @@ void EthernetComponent::dump_config() {
case ETHERNET_TYPE_ENC28J60:
eth_type = "ENC28J60";
break;
#elif defined(USE_ETHERNET_CH390)
case ETHERNET_TYPE_CH390:
eth_type = "CH390";
break;
#endif
#ifdef USE_ETHERNET_OPENETH
case ETHERNET_TYPE_OPENETH:
-2
View File
@@ -36,7 +36,6 @@ from esphome.const import (
CONF_WEIGHT,
)
from esphome.core import CORE, HexInt
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -320,7 +319,6 @@ def download_gfont(value):
if not external_files.is_file_recent(path, value[CONF_REFRESH]):
_LOGGER.debug("download_gfont: path=%s", path)
try:
ensure_happy_eyeballs()
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
req.raise_for_status()
except requests.exceptions.RequestException as e:
@@ -1,33 +0,0 @@
import esphome.codegen as cg
from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@zweckj"]
DEPENDENCIES = ["modbus"]
MULTI_CONF = True
CONF_HOERMANN_HCP_ID = "hoermann_hcp_id"
hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp")
HoermannHcp = hoermann_hcp_ns.class_(
"HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice
)
# The Hoermann UAP module answers on Modbus server address 2.
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)})
.extend(cv.polling_component_schema("500ms"))
.extend(modbus.modbus_device_schema(0x02, role="server"))
)
FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device(
"hoermann_hcp", role="server"
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_server_device(var, config)
@@ -1,22 +0,0 @@
import esphome.codegen as cg
from esphome.components import cover
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component)
CONFIG_SCHEMA = (
cover.cover_schema(HoermannHcpCover)
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
var = await cover.new_cover(config, parent)
await cg.register_component(var, config)
@@ -1,87 +0,0 @@
#include "hoermann_hcp_cover.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp.cover";
cover::CoverTraits HoermannHcpCover::get_traits() {
cover::CoverTraits traits;
traits.set_supports_position(true);
traits.set_supports_stop(true);
traits.set_supports_toggle(true);
return traits;
}
void HoermannHcpCover::setup() {
// Nothing is published before the bus controller is heard from, and the untouched position reads as fully
// open, so flag the entity until the first contact clears it again.
this->status_set_warning("waiting for the bus controller");
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
}
void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); }
void HoermannHcpCover::control(const cover::CoverCall &call) {
bool accepted = true;
if (call.get_stop())
accepted &= this->parent_->stop_door();
if (call.get_toggle().has_value())
accepted &= this->parent_->impulse_door();
if (const auto position = call.get_position())
accepted &= this->parent_->set_position(*position);
if (!accepted) {
// The command never reached the door, so publish the unchanged state over the one the caller assumed.
ESP_LOGW(TAG, "Command was not accepted by the door");
this->publish_state(false);
}
}
void HoermannHcpCover::update_from_state_() {
if (!this->parent_->is_valid()) {
this->status_set_warning();
// The door can now move unheard, so drop the baseline a direction would be inferred from and stop
// reporting motion instead of leaving the cover travelling until the controller returns.
this->previous_position_ = NAN;
if (this->current_operation != cover::COVER_OPERATION_IDLE) {
this->current_operation = cover::COVER_OPERATION_IDLE;
this->publish_state();
}
return;
}
this->status_clear_warning();
const auto previous_operation = this->current_operation;
const float current_position = this->parent_->get_current_position();
switch (this->parent_->get_door_state()) {
case DoorState::OPENING:
this->current_operation = cover::COVER_OPERATION_OPENING;
break;
case DoorState::CLOSING:
this->current_operation = cover::COVER_OPERATION_CLOSING;
break;
case DoorState::MOVE_VENTING:
case DoorState::MOVE_HALF:
// These states carry no direction, so keep the current one until the position actually moves.
if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) {
this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING
: cover::COVER_OPERATION_CLOSING;
}
break;
default:
this->current_operation = cover::COVER_OPERATION_IDLE;
break;
}
this->previous_position_ = current_position;
// Compare against the position last published, which starts at COVER_OPEN rather than at zero.
const bool changed = this->position != current_position || previous_operation != this->current_operation;
this->position = current_position;
if (changed) {
// The bus reports the position on every broadcast, so nothing here is worth restoring from flash.
this->publish_state(false);
}
}
} // namespace esphome::hoermann_hcp
@@ -1,27 +0,0 @@
#pragma once
#include <cmath>
#include "esphome/components/cover/cover.h"
#include "esphome/core/component.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
class HoermannHcpCover : public cover::Cover, public Component {
public:
explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {}
void setup() override;
void dump_config() override;
cover::CoverTraits get_traits() override;
void control(const cover::CoverCall &call) override;
protected:
void update_from_state_();
HoermannHcp *const parent_;
// NAN until the first position is observed, so no direction is inferred from a baseline that never existed.
float previous_position_{NAN};
};
} // namespace esphome::hoermann_hcp
@@ -1,336 +0,0 @@
#include "hoermann_hcp.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp";
// Hoermann HCP holding-register blocks.
static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
// its low byte tells a plain stop from the vent position.
struct DoorStateMapping {
uint8_t code;
DoorState state;
};
static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
{0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF},
{0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN},
{0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN},
};
// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
static void push_zeros(modbus::RegisterValues &registers, uint16_t count) {
for (uint16_t i = 0; i < count; i++)
registers.push_back(0x0000);
}
// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
static bool is_moving(DoorState state) {
switch (state) {
case DoorState::OPENING:
case DoorState::CLOSING:
case DoorState::MOVE_HALF:
case DoorState::MOVE_VENTING:
return true;
default:
return false;
}
}
void HoermannHcp::update() {
const uint32_t now = millis();
// Time out the connection flag if the bus controller stopped polling.
if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
this->set_valid_(false);
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
this->clear_target_();
}
if (this->changed_) {
this->changed_ = false;
this->state_callback_.call();
}
}
void HoermannHcp::dump_config() {
ESP_LOGCONFIG(TAG,
"Hoermann HCP bridge:\n"
" Modbus server address: 0x%02X",
this->get_address());
}
modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) {
if (start_address != STATE_REG) {
ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message
// counter (high byte) and command (low byte). The read length identifies which internal block is requested.
const uint16_t counter = this->command_reg_value_ & 0xFF00;
const uint16_t command = static_cast<uint16_t>((this->command_reg_value_ & 0x00FF) << 8);
switch (number_of_registers) {
case 8:
// Command request: return the internal state, injecting any pending command.
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
push_zeros(registers, 4);
break;
case 2:
// Empty command request.
registers.push_back(static_cast<uint16_t>(0x0004 | counter));
registers.push_back(command);
break;
case 5:
// Bus scan (the bus controller discovering us, typically at startup).
ESP_LOGD(TAG, "Bus scan received from bus controller");
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0005 | command));
registers.push_back(0x0430);
registers.push_back(0x10FF);
registers.push_back(0xA845);
break;
default:
ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers);
push_zeros(registers, number_of_registers);
break;
}
return {};
}
modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
const modbus::RegisterValues &registers) {
if (start_address == COMMAND_REG) {
// 0x17 write half: stash the command register so the following read half can echo its message counter and
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
this->record_response_();
this->command_reg_value_ = registers[0];
return {};
}
if (start_address != BROADCAST_REG) {
// Every device sees every broadcast, so a frame meant for another node is ordinary traffic
ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
// position checks the target against the new state.
if (registers.size() > 2)
this->on_state_reg_(registers[2]);
if (registers.size() > 1)
this->on_position_reg_(registers[1]);
return {};
}
void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
const HoermannHcpCommand *command = this->next_command_;
if (command == nullptr) {
push_zeros(registers, 2);
return;
}
if (this->command_written_at_ == 0) {
// First read after the command was queued: present the "key pressed" values.
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(0x0000);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Still inside the key-press window, so keep presenting 0x0000.
push_zeros(registers, 2);
return;
}
// Enough time passed: present the "key released" values and clear the command.
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
this->next_command_ = nullptr;
registers.push_back(command->released_value);
registers.push_back(0x0000);
}
void HoermannHcp::on_position_reg_(uint16_t value) {
// Low byte: current position.
const uint8_t position = static_cast<uint8_t>(value);
if (this->position_raw_ == position)
return;
this->position_raw_ = position;
this->update_current_position_();
// Until the door actually travels the way it was told to, its position says nothing about the target.
if (!this->has_target_() || !this->target_started_)
return;
// The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
const bool reached = this->target_direction_ == DoorState::OPENING
? this->current_position_ >= this->target_position_
: this->current_position_ <= this->target_position_;
if (reached)
this->stop_door();
}
void HoermannHcp::on_state_reg_(uint16_t value) {
// The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
const uint16_t previous = this->prev_state_reg_;
this->prev_state_reg_ = value;
if (previous == value)
return;
const uint8_t state = value >> 8;
if (state == 0x00) {
// Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
return;
}
for (const auto &mapping : DOOR_STATE_MAPPINGS) {
if (mapping.code == state) {
this->set_door_state_(mapping.state);
return;
}
}
// The low byte can change on its own, so only report a state we cannot decode once.
if (state != (previous >> 8))
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
}
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
if (!this->valid_) {
// Queueing now would fire the command whenever the controller comes back, which may be much later.
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
return false;
}
if (this->next_command_ != nullptr) {
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::stop_door() {
if (!is_moving(this->door_state_)) {
this->clear_target_();
return true;
}
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
return this->queue_command_(COMMAND_IMPULSE);
}
bool HoermannHcp::set_position(float position) {
// The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
if (position <= CLOSE_POSITION_THRESHOLD)
return this->close_door();
if (position >= OPEN_POSITION_THRESHOLD)
return this->open_door();
// Asking the door to travel to where it already is means stopping it.
if (position == this->current_position_)
return this->stop_door();
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
const bool opening = position > this->current_position_;
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
return false;
this->target_position_ = position;
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
// A door already travelling that way is on its way; one moving the other way has to turn around first.
this->target_started_ = this->door_state_ == this->target_direction_;
return true;
}
void HoermannHcp::record_response_() {
this->last_response_ = millis();
this->set_valid_(true);
}
void HoermannHcp::set_valid_(bool valid) {
if (this->valid_ == valid)
return;
this->valid_ = valid;
this->changed_ = true;
if (valid) {
ESP_LOGI(TAG, "Bus controller connected");
return;
}
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
}
void HoermannHcp::set_door_state_(DoorState state) {
if (this->door_state_ == state)
return;
this->door_state_ = state;
this->changed_ = true;
this->update_current_position_();
if (!this->has_target_())
return;
if (state == this->target_direction_) {
this->target_started_ = true;
} else if (this->target_started_ && !is_moving(state)) {
// The door came to rest without reaching the target, so the request it belonged to is over.
this->clear_target_();
}
}
void HoermannHcp::update_current_position_() {
// Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
// trust the reported end stop over the raw count.
float position = static_cast<float>(this->position_raw_) / 200.0f;
if (this->door_state_ == DoorState::CLOSED) {
position = 0.0f;
} else if (this->door_state_ == DoorState::OPEN) {
position = 1.0f;
}
if (this->current_position_ != position) {
this->current_position_ = position;
this->changed_ = true;
}
}
void HoermannHcp::clear_target_() {
this->target_position_ = 0.0f;
this->target_started_ = false;
}
} // namespace esphome::hoermann_hcp
@@ -1,110 +0,0 @@
#pragma once
#include <utility>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::hoermann_hcp {
// Door state as reported by the Hoermann bus controller.
enum class DoorState : uint8_t {
OPEN,
OPENING,
CLOSED,
CLOSING,
HALF_OPEN,
MOVE_VENTING,
VENT,
MOVE_HALF,
STOPPED,
};
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. The second command register remains zero.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
uint16_t released_value;
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
public:
void update() override;
void dump_config() override;
// Registered by child entities to be notified when the door state changes.
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
// Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write
// half first, storing the command register that the read half echoes back) and broadcasts status with 0x10.
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues &registers) override;
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) override;
// Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus
// controller cannot be asked right now, so the caller can react.
bool open_door();
bool close_door();
bool impulse_door();
bool stop_door();
bool set_position(float position);
DoorState get_door_state() const { return this->door_state_; }
float get_current_position() const { return this->current_position_; }
bool is_valid() const { return this->valid_; }
protected:
void record_response_();
// Returns false when the bus controller has not fetched the previous command yet.
bool queue_command_(const HoermannHcpCommand &command);
// Appends the two key-press registers and advances the pending command's press/release state.
void push_command_registers_(modbus::RegisterValues &registers);
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void set_valid_(bool valid);
void set_door_state_(DoorState state);
// Recomputes the reported position from position_raw_ and the current door state.
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
void clear_target_();
CallbackManager<void()> state_callback_;
float current_position_{0.0f};
// Position the door was told to travel to; 0.0 means no target is armed.
float target_position_{0.0f};
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
uint32_t command_queued_at_{0};
uint32_t command_written_at_{0};
uint32_t last_response_{0};
// A command is "pressed" for this long before its end value is sent.
uint16_t key_press_delay_ms_{100};
// Drop the "connected" flag if the bus controller has not polled us for this long.
uint16_t connection_timeout_ms_{2000};
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
// it reads 0x0000.
uint16_t prev_state_reg_{0xFFFF};
// 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message
// counter and low-byte command back from STATE_REG.
uint16_t command_reg_value_{0};
DoorState door_state_{DoorState::CLOSED};
// Direction the door was started in for the current target. A target armed while the door is still travelling
// the other way must not be judged by the reported direction until the door has turned around.
DoorState target_direction_{DoorState::STOPPED};
// Position as reported by the bus controller, 0..200 across the full travel.
uint8_t position_raw_{0};
bool target_started_{false};
bool valid_{false};
bool changed_{false};
};
} // namespace esphome::hoermann_hcp
+2 -3
View File
@@ -8,7 +8,6 @@
#endif
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
namespace esphome::ld2410 {
@@ -179,7 +178,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
}
void LD2410Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -512,7 +511,7 @@ bool LD2410Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice {
uint8_t out_pin_level_ = 0;
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
bool bluetooth_on_{false};
#ifdef USE_NUMBER
+2 -2
View File
@@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
}
void LD2412Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice {
uint8_t out_pin_level_ = 0;
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
bool bluetooth_on_{false};
bool dynamic_background_correction_active_{false};
+2 -2
View File
@@ -184,7 +184,7 @@ void LD2450Component::setup() {
}
void LD2450Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_s[18];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
uint32_t moving_presence_millis_ = 0;
uint32_t timeout_ = 5;
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t zone_type_ = 0;
+2 -1
View File
@@ -45,7 +45,8 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X";
// Helper function to format MAC address with stack allocation
// Returns pointer to UNKNOWN_MAC constant or formatted buffer
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator)
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, 18> buffer) {
if (mac_address_is_valid(mac_address)) {
format_mac_addr_upper(mac_address, buffer.data());
return buffer.data();
+1 -2
View File
@@ -3,7 +3,6 @@ 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 esphome.types import ConfigType
from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE
@@ -15,7 +14,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b")
LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice)
def _validate_wakeup_options(config: ConfigType) -> ConfigType:
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
+15 -31
View File
@@ -4,35 +4,24 @@ import esphome.config_validation as cv
from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY
from . import LD6002BComponent
from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS
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)
}
)
.extend(
{
cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_OCCUPANCY,
)
for i in range(AREA_COUNT)
}
)
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)
}
)
@@ -47,8 +36,3 @@ async def to_code(config):
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))
for i in range(AREA_COUNT):
if area_config := config.get(f"detection_area_{i}"):
sens = await binary_sensor.new_binary_sensor(area_config)
cg.add(hub.set_area_presence_binary_sensor(i, sens))
+1 -39
View File
@@ -2,21 +2,15 @@ import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_AREA_ID,
CONF_ID,
CONF_WAKEUP_PIN,
ENTITY_CATEGORY_CONFIG,
ENTITY_CATEGORY_DIAGNOSTIC,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_APPLY_AREA,
CONF_AUTO_INTERFERENCE,
CONF_CLEAR_INTERFERENCE,
CONF_GET_AREAS,
CONF_GET_DELAY,
CONF_GET_INSTALLATION,
CONF_GET_LOW_POWER_MODE,
@@ -25,7 +19,6 @@ from ..const import (
CONF_GET_TRIGGER_SPEED,
CONF_GET_Z_RANGE,
CONF_LD6002B_ID,
CONF_RESET_DETECTION_AREA,
CONF_RESET_UNATTENDED,
CONF_WAKE,
)
@@ -38,21 +31,6 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_APPLY_AREA): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_GET_AREAS): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_GET_DELAY): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
@@ -84,21 +62,10 @@ CONFIG_SCHEMA = cv.Schema(
)
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config):
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
if config.get(CONF_APPLY_AREA):
has_area_id_select = any(
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None
for entry in full_config.get("select", [])
)
if not has_area_id_select:
raise cv.Invalid(
f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance",
path=[CONF_APPLY_AREA],
)
if config.get(CONF_WAKE):
hub_path = full_config.get_path_for_id(hub_id)
hub_config = full_config.get_config_for_path(hub_path[:-1])
@@ -114,11 +81,6 @@ def final_validate(config: ConfigType) -> ConfigType:
FINAL_VALIDATE_SCHEMA = final_validate
BUTTON_MAP = {
CONF_APPLY_AREA: ButtonType.APPLY_AREA,
CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE,
CONF_GET_AREAS: ButtonType.GET_AREAS,
CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE,
CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA,
CONF_GET_DELAY: ButtonType.GET_DELAY,
CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY,
CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED,
-12
View File
@@ -1,11 +1,6 @@
CONF_APPLY_AREA = "apply_area"
CONF_AREA_CONFIG = "area_config"
CONF_AUTO_INTERFERENCE = "auto_interference"
CONF_AUTO_WAKE = "auto_wake"
CONF_CLEAR_INTERFERENCE = "clear_interference"
CONF_CLUSTER_ID = "cluster_id"
CONF_DOPPLER_INDEX = "doppler_index"
CONF_GET_AREAS = "get_areas"
CONF_GET_DELAY = "get_delay"
CONF_GET_INSTALLATION = "get_installation"
CONF_GET_LOW_POWER_MODE = "get_low_power_mode"
@@ -21,7 +16,6 @@ CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time"
CONF_OTA_VERSION = "ota_version"
CONF_POINT_CLOUD = "point_cloud"
CONF_POINT_COUNT = "point_count"
CONF_RESET_DETECTION_AREA = "reset_detection_area"
CONF_RESET_UNATTENDED = "reset_unattended"
CONF_TARGET_DISPLAY = "target_display"
CONF_TRIGGER_SPEED = "trigger_speed"
@@ -32,10 +26,4 @@ CONF_Z = "z"
CONF_Z_MAX = "z_max"
CONF_Z_MIN = "z_min"
KEY_X_MIN = "x_min"
KEY_X_MAX = "x_max"
KEY_Y_MIN = "y_min"
KEY_Y_MAX = "y_max"
AREA_COUNT = 4
MAX_TARGETS = 3
+13 -575
View File
@@ -15,16 +15,12 @@ static constexpr uint32_t SETUP_DELAY_MS = 100;
// Command/message types
static constexpr uint16_t TYPE_CONTROL = 0x0201;
static constexpr uint16_t TYPE_SET_AREA = 0x0202;
static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203;
static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204;
static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205;
static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04;
static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08;
static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A;
static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B;
static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C;
static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D;
static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E;
static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F;
@@ -36,10 +32,6 @@ static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14;
static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF;
// Control command values for TYPE_CONTROL
static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01;
static constexpr uint32_t CMD_GET_AREAS = 0x02;
static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03;
static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04;
static constexpr uint32_t CMD_GET_DELAY = 0x05;
static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06;
static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07;
@@ -63,26 +55,13 @@ static constexpr uint32_t CMD_GET_LOW_POWER = 0x18;
static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19;
static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A;
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats
static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats
static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area
static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00};
#ifdef ESPHOME_LOG_HAS_VERBOSE
static const char *control_command_name(uint32_t command) {
switch (command) {
case CMD_AUTO_INTERFERENCE:
return "auto_interference";
case CMD_GET_AREAS:
return "get_areas";
case CMD_CLEAR_INTERFERENCE:
return "clear_interference";
case CMD_RESET_DETECTION_AREA:
return "reset_detection_area";
case CMD_GET_DELAY:
return "get_delay";
case CMD_POINT_CLOUD_ON:
@@ -136,8 +115,6 @@ static const char *frame_type_name(uint16_t type) {
switch (type) {
case TYPE_CONTROL:
return "control";
case TYPE_SET_AREA:
return "set_area";
case TYPE_SET_HOLD_DELAY:
return "set_hold_delay";
case TYPE_SET_Z_RANGE:
@@ -148,12 +125,6 @@ static const char *frame_type_name(uint16_t type) {
return "report_target";
case TYPE_REPORT_POINT_CLOUD:
return "report_point_cloud";
case TYPE_REPORT_AREA_PRESENCE:
return "report_area_presence";
case TYPE_REPORT_INTERFERENCE_AREAS:
return "report_interference_areas";
case TYPE_REPORT_DETECTION_AREAS:
return "report_detection_areas";
case TYPE_REPORT_DELAY:
return "report_delay";
case TYPE_REPORT_SENSITIVITY:
@@ -179,8 +150,6 @@ static const char *frame_type_name(uint16_t type) {
static bool is_expected_control_report(uint32_t command, uint16_t type) {
switch (command) {
case CMD_GET_AREAS:
return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS;
case CMD_GET_DELAY:
return type == TYPE_REPORT_DELAY;
case CMD_GET_SENSITIVITY:
@@ -231,10 +200,6 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) {
data[3] = (value >> 24) & 0xFF;
}
void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) {
write_u32_le(data, static_cast<uint32_t>(value));
}
void LD6002BComponent::write_f32_le(uint8_t *data, float value) {
uint32_t raw;
std::memcpy(&raw, &value, sizeof(raw));
@@ -391,37 +356,6 @@ void LD6002BComponent::setup() {
this->send_control_command_(CMD_GET_LOW_POWER);
}
bool want_area_report = false;
#ifdef USE_SENSOR
for (const auto &area : this->interference_areas_) {
if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr ||
area.z_min != nullptr || area.z_max != nullptr) {
want_area_report = true;
break;
}
}
if (!want_area_report) {
for (const auto &area : this->detection_areas_) {
if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr ||
area.z_min != nullptr || area.z_max != nullptr) {
want_area_report = true;
break;
}
}
}
#endif
#ifdef USE_NUMBER
if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr ||
this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr ||
this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) {
want_area_report = true;
}
#endif
if (want_area_report) {
this->send_control_command_(CMD_GET_AREAS);
}
this->init_area_id_pref_();
this->init_version_pref_();
#ifdef USE_TEXT_SENSOR
@@ -440,7 +374,7 @@ void LD6002BComponent::dump_config() {
this->auto_wake_ ? "true" : "false", static_cast<unsigned>(this->max_data_len_));
if (this->wakeup_pin_ != nullptr) {
LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_);
ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_);
ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_);
}
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Target Count", this->target_count_sensor_);
@@ -452,31 +386,12 @@ void LD6002BComponent::dump_config() {
LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx);
LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id);
}
for (auto &area : this->interference_areas_) {
LOG_SENSOR(" ", "Interference Area X Min", area.x_min);
LOG_SENSOR(" ", "Interference Area X Max", area.x_max);
LOG_SENSOR(" ", "Interference Area Y Min", area.y_min);
LOG_SENSOR(" ", "Interference Area Y Max", area.y_max);
LOG_SENSOR(" ", "Interference Area Z Min", area.z_min);
LOG_SENSOR(" ", "Interference Area Z Max", area.z_max);
}
for (auto &area : this->detection_areas_) {
LOG_SENSOR(" ", "Detection Area X Min", area.x_min);
LOG_SENSOR(" ", "Detection Area X Max", area.x_max);
LOG_SENSOR(" ", "Detection Area Y Min", area.y_min);
LOG_SENSOR(" ", "Detection Area Y Max", area.y_max);
LOG_SENSOR(" ", "Detection Area Z Min", area.z_min);
LOG_SENSOR(" ", "Detection Area Z Max", area.z_max);
}
#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]);
}
for (uint8_t i = 0; i < AREA_COUNT; i++) {
LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]);
}
#endif
#ifdef USE_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_);
@@ -487,12 +402,6 @@ void LD6002BComponent::dump_config() {
LOG_NUMBER(" ", "Z Min", this->z_min_number_);
LOG_NUMBER(" ", "Z Max", this->z_max_number_);
LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_);
LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_);
LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_);
LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_);
LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_);
LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_);
LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_);
#endif
#ifdef USE_SWITCH
LOG_SWITCH(" ", "Low Power", this->low_power_switch_);
@@ -503,7 +412,6 @@ void LD6002BComponent::dump_config() {
LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_);
LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_);
LOG_SELECT(" ", "Installation Mode", this->installation_select_);
LOG_SELECT(" ", "Area ID", this->area_id_select_);
#endif
}
@@ -619,7 +527,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
}
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_);
const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_;
// 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;
@@ -629,10 +536,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
this->command_sent_ = false;
this->last_send_ms_ = 0;
this->process_command_queue_();
if (refresh_areas) {
this->area_write_in_flight_ = false;
this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); });
}
return;
}
@@ -654,15 +557,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
case TYPE_REPORT_POINT_CLOUD:
this->handle_point_cloud_(data, len);
break;
case TYPE_REPORT_AREA_PRESENCE:
this->handle_area_presence_(data, len);
break;
case TYPE_REPORT_INTERFERENCE_AREAS:
this->handle_area_report_(true, data, len);
break;
case TYPE_REPORT_DETECTION_AREAS:
this->handle_area_report_(false, data, len);
break;
case TYPE_REPORT_DELAY:
this->handle_delay_report_(data, len);
break;
@@ -760,9 +654,8 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len)
this->target_presence_any_ = (reported > 0);
#ifdef USE_BINARY_SENSOR
bool presence = this->target_presence_any_ || this->area_presence_any_;
if (this->presence_binary_sensor_ != nullptr) {
this->presence_binary_sensor_->publish_state(presence);
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
}
#endif
this->update_work_mode_fallback_();
@@ -835,84 +728,6 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) {
#endif
}
// 0x0A0A carries one uint32 per detection area -- the protocol names the four
// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The
// interference areas have no presence report: a target inside one is what they
// exist to suppress.
void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) {
const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN;
if (len < needed)
return;
this->area_presence_any_ = false;
for (uint8_t i = 0; i < AREA_COUNT; i++) {
uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN));
bool present = state != 0;
this->area_presence_any_ = this->area_presence_any_ || present;
#ifdef USE_BINARY_SENSOR
if (this->area_presence_[i] != nullptr) {
this->area_presence_[i]->publish_state(present);
}
#endif
}
#ifdef USE_BINARY_SENSOR
bool presence = this->target_presence_any_ || this->area_presence_any_;
if (this->presence_binary_sensor_ != nullptr) {
this->presence_binary_sensor_->publish_state(presence);
}
#endif
this->update_work_mode_fallback_();
}
void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) {
uint16_t needed = AREA_COUNT * AREA_DATA_LEN;
if (len < needed)
return;
for (uint8_t i = 0; i < AREA_COUNT; i++) {
uint16_t offset = i * AREA_DATA_LEN;
float x_min = read_f32_le(data + offset + 0);
float x_max = read_f32_le(data + offset + 4);
float y_min = read_f32_le(data + offset + 8);
float y_max = read_f32_le(data + offset + 12);
float z_min = read_f32_le(data + offset + 16);
float z_max = read_f32_le(data + offset + 20);
#ifdef USE_SENSOR
AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i];
if (area.x_min != nullptr)
area.x_min->publish_state(x_min);
if (area.x_max != nullptr)
area.x_max->publish_state(x_max);
if (area.y_min != nullptr)
area.y_min->publish_state(y_min);
if (area.y_max != nullptr)
area.y_max->publish_state(y_max);
if (area.z_min != nullptr)
area.z_min->publish_state(z_min);
if (area.z_max != nullptr)
area.z_max->publish_state(z_max);
#endif
AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i];
store.x_min = x_min;
store.x_max = x_max;
store.y_min = y_min;
store.y_max = y_max;
store.z_min = z_min;
store.z_max = z_max;
uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
bool selected_interference = selected_id < AREA_COUNT;
uint8_t selected_index = selected_interference ? selected_id : static_cast<uint8_t>(selected_id - AREA_COUNT);
if (selected_interference == interference && selected_index == i) {
this->update_area_numbers_(store);
}
}
this->try_apply_pending_area_(interference);
}
void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) {
if (len < 4)
return;
@@ -1000,24 +815,13 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1
void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
// Zero is the unattended half of this transition. Read outside the text sensor's
// ifdef because the area sensors do not need one configured to have gone stale.
const bool low_power = (data[0] == 0);
#ifdef USE_TEXT_SENSOR
const bool low_power = (data[0] == 0);
if (this->work_mode_text_sensor_ != nullptr) {
this->work_mode_reported_ = true;
this->publish_work_mode_(low_power);
}
#endif
// Protocol V1.2 section 2.1.17: this message is sent only on the transition
// between the unattended low-power mode and normal operation, so a zero is the
// module stating that nobody is in any area. Not while a target is still being
// tracked, though: the reset_unattended command is undocumented on whether it
// forces this report, and where two statements from the module disagree the live
// one wins.
if (low_power && !this->target_presence_any_) {
this->clear_area_presence_();
}
}
void LD6002BComponent::update_work_mode_fallback_() {
@@ -1028,10 +832,9 @@ void LD6002BComponent::update_work_mode_fallback_() {
if (!this->low_power_reported_) {
return;
}
// Target presence is only meaningful while the stream that maintains it runs.
// Area presence keeps its own report, so it still counts with the target stream
// off and low power alone decides only when neither half has anything to say.
const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_;
// Presence is only meaningful while the stream that maintains it runs; with it
// off there is nothing to weigh and low power alone decides.
const bool presence = this->target_display_enabled_ && this->target_presence_any_;
this->publish_work_mode_(this->low_power_enabled_ && !presence);
#endif
}
@@ -1054,16 +857,6 @@ void LD6002BComponent::publish_work_mode_(bool low_power) {
void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) {
if (number == nullptr)
return;
if (std::isnan(value)) {
// NAN is this component's "the module has not told us yet". Publishing it on an
// entity that has never had a state would report a nan where unknown is the
// truth; on one that already shows a value it is the only way to say that value
// no longer describes the selected area.
if (number->has_state()) {
number->publish_state(value);
}
return;
}
const float min_value = number->traits.get_min_value();
const float max_value = number->traits.get_max_value();
// Outside the declared range the user cannot write the value back, so publish
@@ -1098,14 +891,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len)
#endif
}
bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) {
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 false;
return;
}
if (this->cmd_count_ >= CMD_QUEUE_SIZE) {
ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type);
return false;
return;
}
PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_];
@@ -1118,7 +911,6 @@ bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_
this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE;
this->cmd_count_++;
this->process_command_queue_();
return true;
}
void LD6002BComponent::process_command_queue_() {
@@ -1153,18 +945,6 @@ void LD6002BComponent::process_command_queue_() {
} else {
ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type);
}
if (this->active_command_.type == TYPE_SET_AREA) {
this->area_write_in_flight_ = false;
}
// The deferred apply is waiting on the report this command would have
// brought back, and nothing else re-arms it. Dropping it here is the
// difference between one apply lost to a timeout and one that rides in on
// an unrelated area report later, writing bounds the user has moved on from.
if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) {
this->deferred_apply_pending_ = false;
this->restore_deferred_edits_();
ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply");
}
// 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
@@ -1287,10 +1067,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t
this->last_traffic_ms_ = now;
}
bool LD6002BComponent::send_control_command_(uint32_t command) {
void LD6002BComponent::send_control_command_(uint32_t command) {
uint8_t data[4];
write_u32_le(data, command);
return this->queue_command_(TYPE_CONTROL, data, sizeof(data));
this->queue_command_(TYPE_CONTROL, data, sizeof(data));
}
void LD6002BComponent::send_z_range_() {
@@ -1310,64 +1090,6 @@ void LD6002BComponent::send_z_range_() {
this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data));
}
void LD6002BComponent::apply_area_config_() {
if (!this->area_id_set_) {
ESP_LOGW(TAG, "Area ID not selected; ignoring apply");
return;
}
if (this->area_id_ >= AREA_ID_COUNT) {
ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_);
return;
}
const bool interference = this->area_id_ < AREA_COUNT;
const uint8_t index = interference ? this->area_id_ : static_cast<uint8_t>(this->area_id_ - AREA_COUNT);
AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
if (!std::isnan(this->area_x_min_))
desired.x_min = this->area_x_min_;
if (!std::isnan(this->area_x_max_))
desired.x_max = this->area_x_max_;
if (!std::isnan(this->area_y_min_))
desired.y_min = this->area_y_min_;
if (!std::isnan(this->area_y_max_))
desired.y_max = this->area_y_max_;
if (!std::isnan(this->area_z_min_))
desired.z_min = this->area_z_min_;
if (!std::isnan(this->area_z_max_))
desired.z_max = this->area_z_max_;
if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) ||
std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) {
// Ask first: a read that never reached the queue would leave a deferral waiting
// on a report nobody requested, with the user's values already retired for it.
if (!this->send_control_command_(CMD_GET_AREAS)) {
ESP_LOGW(TAG, "Area read not queued; area config left unapplied");
return;
}
this->deferred_apply_pending_ = true;
this->pending_area_id_ = this->area_id_;
// The ledger, not the mirror: the mirror also carries whatever the module last
// reported for the axes the user never touched, and staging those would hand them
// back later wearing the user's badge -- a module value the next report is then
// kept away from. Staging only what was actually typed is also what makes the
// replay's overlay right: the untouched axes come from the fresh report. An
// empty ledger is a meaning rather than a gap, then: an apply with nothing
// staged rewrites the area exactly as the report just described it, which is
// what a direct apply with nothing staged already does.
this->pending_area_updates_ = this->area_edits_;
// Staged above, so they are the deferred apply's values now rather than an
// unsent edit. Anything typed from here belongs to whatever the user does
// next, which may well be a different area.
this->area_edits_ = AreaConfig{};
ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying");
return;
}
// Only a write the module will actually see retires them.
if (this->queue_area_config_(this->area_id_, desired)) {
this->area_edits_ = AreaConfig{};
}
}
void LD6002BComponent::wake_() {
// A command's own pulse raises the pin and writes after it, so ride along instead of
// claiming the flag: claiming it would send that command down the immediate-write path
@@ -1402,30 +1124,6 @@ void LD6002BComponent::set_number_value(NumberType type, float value) {
this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data));
break;
}
case NumberType::AREA_X_MIN:
this->area_x_min_ = value;
this->area_edits_.x_min = value;
break;
case NumberType::AREA_X_MAX:
this->area_x_max_ = value;
this->area_edits_.x_max = value;
break;
case NumberType::AREA_Y_MIN:
this->area_y_min_ = value;
this->area_edits_.y_min = value;
break;
case NumberType::AREA_Y_MAX:
this->area_y_max_ = value;
this->area_edits_.y_max = value;
break;
case NumberType::AREA_Z_MIN:
this->area_z_min_ = value;
this->area_edits_.z_min = value;
break;
case NumberType::AREA_Z_MAX:
this->area_z_max_ = value;
this->area_edits_.z_max = value;
break;
}
}
@@ -1456,179 +1154,9 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) {
this->send_control_command_(CMD_INSTALL_SIDE);
}
break;
case SelectType::AREA_ID:
this->area_id_ = static_cast<uint8_t>(index);
this->area_id_set_ = true;
this->update_area_numbers_for_id_(this->area_id_);
this->save_area_id_pref_(this->area_id_);
break;
}
}
void LD6002BComponent::update_area_numbers_(const AreaConfig &area) {
// A report refreshes every axis the user is not in the middle of changing. An
// unapplied edit is the one value here the module cannot know about, so taking
// the report over it would discard what the user typed with nothing to show for it.
const AreaConfig &edits = this->area_edits_;
if (std::isnan(edits.x_min))
this->area_x_min_ = area.x_min;
if (std::isnan(edits.x_max))
this->area_x_max_ = area.x_max;
if (std::isnan(edits.y_min))
this->area_y_min_ = area.y_min;
if (std::isnan(edits.y_max))
this->area_y_max_ = area.y_max;
if (std::isnan(edits.z_min))
this->area_z_min_ = area.z_min;
if (std::isnan(edits.z_max))
this->area_z_max_ = area.z_max;
this->publish_area_numbers_();
}
// The mirror, not the report: an axis a report was kept away from has to keep its
// displayed value too, or the entity and the value the next apply sends disagree.
void LD6002BComponent::publish_area_numbers_() {
#ifdef USE_NUMBER
this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_);
this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_);
this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_);
this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_);
this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_);
this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_);
#endif
}
void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) {
if (area_id >= AREA_ID_COUNT)
return;
const bool interference = area_id < AREA_COUNT;
const uint8_t index = interference ? area_id : static_cast<uint8_t>(area_id - AREA_COUNT);
const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
// The edits belonged to the area being navigated away from.
this->area_edits_ = AreaConfig{};
this->update_area_numbers_(area);
}
bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) {
// One frame carries all three pairs and cannot express a crossed one; the module
// would keep a box nothing can ever be inside. Both callers arrive with the six
// bounds resolved, so this is the last place that can say no -- and the return
// value is how saying no reaches the caller, which must not then retire the edits
// the user still has to fix.
if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) {
ESP_LOGW(TAG, "Area %u not written, min above max", area_id);
return false;
}
uint8_t data[AREA_CONFIG_LEN];
write_int32_le(data, static_cast<int32_t>(area_id));
write_f32_le(data + 4, desired.x_min);
write_f32_le(data + 8, desired.x_max);
write_f32_le(data + 12, desired.y_min);
write_f32_le(data + 16, desired.y_max);
write_f32_le(data + 20, desired.z_min);
write_f32_le(data + 24, desired.z_max);
if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) {
// Nothing is on its way, so the cache must not claim these bounds, the ack
// refresh must not be armed for an ack that cannot come, and the values stay
// the user's unsent edit.
return false;
}
this->area_write_in_flight_ = true;
const bool interference = area_id < AREA_COUNT;
const uint8_t index = interference ? area_id : static_cast<uint8_t>(area_id - AREA_COUNT);
AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
store = desired;
// The six numbers show one area at a time, and a deferred apply can land here for
// an area the user has navigated away from. Same question handle_area_report_
// asks before it touches them.
const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
if (area_id == selected_id) {
this->update_area_numbers_(store);
}
return true;
}
void LD6002BComponent::try_apply_pending_area_(bool reported_interference) {
if (!this->deferred_apply_pending_) {
return;
}
if (this->pending_area_id_ >= AREA_ID_COUNT) {
this->deferred_apply_pending_ = false;
return;
}
const bool interference = this->pending_area_id_ < AREA_COUNT;
const uint8_t index =
interference ? this->pending_area_id_ : static_cast<uint8_t>(this->pending_area_id_ - AREA_COUNT);
AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
if (!std::isnan(this->pending_area_updates_.x_min))
desired.x_min = this->pending_area_updates_.x_min;
if (!std::isnan(this->pending_area_updates_.x_max))
desired.x_max = this->pending_area_updates_.x_max;
if (!std::isnan(this->pending_area_updates_.y_min))
desired.y_min = this->pending_area_updates_.y_min;
if (!std::isnan(this->pending_area_updates_.y_max))
desired.y_max = this->pending_area_updates_.y_max;
if (!std::isnan(this->pending_area_updates_.z_min))
desired.z_min = this->pending_area_updates_.z_min;
if (!std::isnan(this->pending_area_updates_.z_max))
desired.z_max = this->pending_area_updates_.z_max;
if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) ||
std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) {
// Only the report covering this area's half can still fill it in, and there is
// exactly one of those per read. Once it has landed with a bound still unknown,
// nothing further is coming and waiting means waiting forever.
if (reported_interference == interference) {
this->deferred_apply_pending_ = false;
this->restore_deferred_edits_();
ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete");
}
return;
}
const uint8_t area_id = this->pending_area_id_;
this->deferred_apply_pending_ = false;
if (!this->queue_area_config_(area_id, desired)) {
// Nothing was queued, so this is a drop like the other two: hand the staged
// values back rather than leaving them with no ledger to protect them.
this->restore_deferred_edits_();
}
}
void LD6002BComponent::init_area_id_pref_() {
#ifdef USE_SELECT
if (this->area_id_select_ == nullptr) {
return;
}
this->area_id_pref_ = this->area_id_select_->make_entity_preference<uint8_t>();
this->area_id_pref_initialized_ = true;
uint8_t value = 0;
if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) {
// No stored selection. The numbers are about to display this area either way,
// so select it for real: a displayed area that apply_area then refuses to write
// is the one combination the user cannot make sense of.
value = AREA_ID_DEFAULT;
}
this->area_id_select_->publish_state(value);
this->area_id_ = value;
this->area_id_set_ = true;
this->update_area_numbers_for_id_(value);
#endif
}
void LD6002BComponent::save_area_id_pref_(uint8_t value) {
#ifdef USE_SELECT
if (!this->area_id_pref_initialized_) {
return;
}
this->area_id_pref_.save(&value);
#endif
}
void LD6002BComponent::init_version_pref_() {
#ifdef USE_TEXT_SENSOR
if (this->ota_version_text_sensor_ == nullptr) {
@@ -1683,74 +1211,6 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) {
}
#endif
void LD6002BComponent::restore_deferred_edits_() {
// The staged values become an unsent edit again, but only for the user who is
// still looking at the area they were staged for; anyone else's ledger belongs to
// the area they are on now.
const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
if (this->pending_area_id_ != selected_id) {
return;
}
// Axis by axis rather than a whole-struct assignment: the user can have edited
// another bound while the deferral was in flight, and that edit is newer than
// anything the deferral staged. Assigning over the ledger would drop it back to
// NaN and let the next report take the value away. A live edit wins; only an axis
// with nothing in the ledger takes its staged value back.
//
// The mirror moves with the ledger, because on the report path handle_area_report_
// ran update_area_numbers_ before the replay, with the ledger still empty -- so the
// mirror already holds the module's bounds and both the entities and the next apply
// would build on them. On the timeout path no report arrived, the mirror still
// holds the staged values, and this is an identity.
const AreaConfig &staged = this->pending_area_updates_;
if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) {
this->area_edits_.x_min = staged.x_min;
this->area_x_min_ = staged.x_min;
}
if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) {
this->area_edits_.x_max = staged.x_max;
this->area_x_max_ = staged.x_max;
}
if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) {
this->area_edits_.y_min = staged.y_min;
this->area_y_min_ = staged.y_min;
}
if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) {
this->area_edits_.y_max = staged.y_max;
this->area_y_max_ = staged.y_max;
}
if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) {
this->area_edits_.z_min = staged.z_min;
this->area_z_min_ = staged.z_min;
}
if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) {
this->area_edits_.z_max = staged.z_max;
this->area_z_max_ = staged.z_max;
}
this->publish_area_numbers_();
}
void LD6002BComponent::clear_area_presence_() {
if (!this->area_presence_any_) {
return;
}
// Nothing else corrects this: 0x0A0A carries no period the protocol states and no
// command stops it, so the module going unattended is the only moment the
// component can know a stored "occupied" has stopped being true.
this->area_presence_any_ = false;
#ifdef USE_BINARY_SENSOR
for (uint8_t i = 0; i < AREA_COUNT; i++) {
if (this->area_presence_[i] != nullptr) {
this->area_presence_[i]->publish_state(false);
}
}
const bool presence = this->target_presence_any_ || this->area_presence_any_;
if (this->presence_binary_sensor_ != nullptr) {
this->presence_binary_sensor_->publish_state(presence);
}
#endif
}
void LD6002BComponent::clear_target_state_() {
// Nothing corrects any of this until the stream comes back. The slot table goes
// with it: slots key on cluster ids, which only track a person while reports are
@@ -1782,9 +1242,8 @@ void LD6002BComponent::clear_target_state_() {
if (this->target_presence_any_) {
this->target_presence_any_ = false;
#ifdef USE_BINARY_SENSOR
bool presence = this->target_presence_any_ || this->area_presence_any_;
if (this->presence_binary_sensor_ != nullptr) {
this->presence_binary_sensor_->publish_state(presence);
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
}
#endif
this->update_work_mode_fallback_();
@@ -1825,27 +1284,6 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) {
void LD6002BComponent::press_button(ButtonType type) {
switch (type) {
case ButtonType::APPLY_AREA:
this->apply_area_config_();
break;
case ButtonType::AUTO_INTERFERENCE:
this->send_control_command_(CMD_AUTO_INTERFERENCE);
// The module recomputes the interference areas without reporting them.
this->send_control_command_(CMD_GET_AREAS);
break;
case ButtonType::GET_AREAS:
this->send_control_command_(CMD_GET_AREAS);
break;
case ButtonType::CLEAR_INTERFERENCE:
this->send_control_command_(CMD_CLEAR_INTERFERENCE);
// The module rewrites the areas but does not report them, so ask for the new geometry the
// way the apply_area ack path does; the queue keeps it behind the command above.
this->send_control_command_(CMD_GET_AREAS);
break;
case ButtonType::RESET_DETECTION_AREA:
this->send_control_command_(CMD_RESET_DETECTION_AREA);
this->send_control_command_(CMD_GET_AREAS);
break;
case ButtonType::GET_DELAY:
this->send_control_command_(CMD_GET_DELAY);
break;
+5 -171
View File
@@ -31,10 +31,6 @@
namespace esphome::ld6002b {
static constexpr uint8_t MAX_TARGETS = 3;
static constexpr uint8_t AREA_COUNT = 4;
// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so
// this is the whole id space TYPE_SET_AREA accepts.
static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2;
static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024;
static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096;
// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes.
@@ -45,19 +41,12 @@ enum class NumberType : uint8_t {
Z_MIN,
Z_MAX,
LOW_POWER_SLEEP,
AREA_X_MIN,
AREA_X_MAX,
AREA_Y_MIN,
AREA_Y_MAX,
AREA_Z_MIN,
AREA_Z_MAX,
};
enum class SelectType : uint8_t {
SENSITIVITY,
TRIGGER_SPEED,
INSTALLATION_MODE,
AREA_ID,
};
enum class SwitchType : uint8_t {
@@ -67,11 +56,6 @@ enum class SwitchType : uint8_t {
};
enum class ButtonType : uint8_t {
APPLY_AREA,
AUTO_INTERFERENCE,
GET_AREAS,
CLEAR_INTERFERENCE,
RESET_DETECTION_AREA,
GET_DELAY,
GET_SENSITIVITY,
GET_TRIGGER_SPEED,
@@ -92,25 +76,8 @@ struct TargetSensors {
sensor::Sensor *cluster_id{nullptr};
};
struct AreaSensors {
sensor::Sensor *x_min{nullptr};
sensor::Sensor *x_max{nullptr};
sensor::Sensor *y_min{nullptr};
sensor::Sensor *y_max{nullptr};
sensor::Sensor *z_min{nullptr};
sensor::Sensor *z_max{nullptr};
};
#endif
struct AreaConfig {
float x_min{NAN};
float x_max{NAN};
float y_min{NAN};
float y_max{NAN};
float z_min{NAN};
float z_max{NAN};
};
struct VersionPref {
char value[20];
};
@@ -155,67 +122,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
return;
this->targets_[target].cluster_id = sensor;
}
void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].x_min = sensor;
}
void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].x_max = sensor;
}
void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].y_min = sensor;
}
void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].y_max = sensor;
}
void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].z_min = sensor;
}
void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->interference_areas_[area].z_max = sensor;
}
void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].x_min = sensor;
}
void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].x_max = sensor;
}
void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].y_min = sensor;
}
void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].y_max = sensor;
}
void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].z_min = sensor;
}
void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) {
if (area >= AREA_COUNT)
return;
this->detection_areas_[area].z_max = sensor;
}
#endif
#ifdef USE_BINARY_SENSOR
@@ -225,11 +131,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
return;
this->target_presence_[target] = sensor;
}
void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) {
if (area >= AREA_COUNT)
return;
this->area_presence_[area] = sensor;
}
#endif
#ifdef USE_TEXT_SENSOR
@@ -242,20 +143,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
void set_z_min_number(number::Number *number) { this->z_min_number_ = number; }
void set_z_max_number(number::Number *number) { this->z_max_number_ = number; }
void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; }
void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; }
void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; }
void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; }
void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; }
void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; }
void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; }
#endif
#ifdef USE_SELECT
void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; }
void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; }
void set_installation_select(select::Select *select) { this->installation_select_ = select; }
void set_area_id_select(select::Select *select) { this->area_id_select_ = select; }
#endif
#ifdef USE_SWITCH
@@ -283,8 +176,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
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 handle_point_cloud_(const uint8_t *data, uint16_t len);
void handle_area_presence_(const uint8_t *data, uint16_t len);
void handle_area_report_(bool interference, const uint8_t *data, uint16_t len);
void handle_delay_report_(const uint8_t *data, uint16_t len);
void handle_sensitivity_report_(const uint8_t *data, uint16_t len);
void handle_trigger_speed_report_(const uint8_t *data, uint16_t len);
@@ -298,35 +189,22 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
void publish_work_mode_(bool low_power);
// Drops every target-derived reading and the slot table they are indexed by.
void clear_target_state_();
void clear_area_presence_();
void restore_deferred_edits_();
void publish_area_numbers_();
#ifdef USE_SENSOR
void clear_target_slot_(uint8_t index);
#endif
#ifdef USE_NUMBER
void publish_number_clamped_(number::Number *number, float value);
#endif
void update_area_numbers_(const AreaConfig &area);
void update_area_numbers_for_id_(uint8_t area_id);
bool queue_area_config_(uint8_t area_id, const AreaConfig &desired);
void try_apply_pending_area_(bool reported_interference);
void init_area_id_pref_();
void save_area_id_pref_(uint8_t value);
void init_version_pref_();
void save_version_pref_(const char *value);
// Returns whether the command was queued: it is dropped, with a log line, when
// the payload is too long or the ring is full.
bool queue_command_(uint16_t type, const uint8_t *data, uint8_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);
// Returns whether the command reached the queue; see queue_command_.
bool send_control_command_(uint32_t command);
void send_control_command_(uint32_t command);
void send_z_range_();
void apply_area_config_();
void wake_();
static uint16_t read_u16_be(const uint8_t *data);
@@ -334,20 +212,16 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
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);
static void write_int32_le(uint8_t *data, int32_t value);
static void write_f32_le(uint8_t *data, float value);
#ifdef USE_SENSOR
std::array<TargetSensors, MAX_TARGETS> targets_{};
sensor::Sensor *target_count_sensor_{nullptr};
sensor::Sensor *point_count_sensor_{nullptr};
std::array<AreaSensors, AREA_COUNT> interference_areas_{};
std::array<AreaSensors, AREA_COUNT> detection_areas_{};
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
std::array<binary_sensor::BinarySensor *, AREA_COUNT> area_presence_{};
#endif
#ifdef USE_TEXT_SENSOR
text_sensor::TextSensor *work_mode_text_sensor_{nullptr};
@@ -360,21 +234,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
number::Number *z_min_number_{nullptr};
number::Number *z_max_number_{nullptr};
number::Number *low_power_sleep_number_{nullptr};
number::Number *area_x_min_number_{nullptr};
number::Number *area_x_max_number_{nullptr};
number::Number *area_y_min_number_{nullptr};
number::Number *area_y_max_number_{nullptr};
number::Number *area_z_min_number_{nullptr};
number::Number *area_z_max_number_{nullptr};
#endif
#ifdef USE_SELECT
select::Select *sensitivity_select_{nullptr};
select::Select *trigger_speed_select_{nullptr};
select::Select *installation_select_{nullptr};
select::Select *area_id_select_{nullptr};
ESPPreferenceObject area_id_pref_{};
bool area_id_pref_initialized_{false};
#endif
#ifdef USE_SWITCH
switch_::Switch *low_power_switch_{nullptr};
@@ -400,13 +264,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
uint8_t *data_buf_{nullptr};
uint16_t next_frame_id_{0};
// Sized for the two bursts that reach it, both counted as what is still queued
// once the first command is dequeued: boot leaves 11 with every platform
// configured, and pressing all fourteen buttons before an ack lands leaves 15.
// Neither overflowed 16, but one free slot is not headroom, and overflowing is a
// dropped command with only a log line to show for it. Costs 256 bytes more per
// configured instance, and this component is MULTI_CONF.
static constexpr uint8_t CMD_QUEUE_SIZE = 24;
// 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;
@@ -416,9 +276,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
// Named so a repeated press replaces its own pending timeout instead of stacking
// another, and so the command path can cancel it when it takes the pin over.
static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button";
// Named so a burst of writes collapses to one read once they settle, rather than
// one read per write.
static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh";
// 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;
@@ -450,24 +307,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
float z_min_{NAN};
float z_max_{NAN};
float area_x_min_{NAN};
float area_x_max_{NAN};
float area_y_min_{NAN};
float area_y_max_{NAN};
float area_z_min_{NAN};
float area_z_max_{NAN};
// What the user has typed and not yet applied; NaN per axis means "nothing of
// mine here, take the module's value". Same sentinel shape as
// pending_area_updates_. Exactly two things empty it: the area_id select moving
// to another area, and an apply that was accepted. A write the bounds guard
// refused leaves it alone, and a deferred apply that had to be dropped hands its
// staged values back here -- but only while the user is still on the area they
// were staged for. Either way the values stay the user's to fix.
AreaConfig area_edits_{};
std::array<AreaConfig, AREA_COUNT> interference_area_values_{};
std::array<AreaConfig, AREA_COUNT> detection_area_values_{};
uint8_t area_id_{0xFF};
bool area_id_set_{false};
// 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_{};
@@ -479,14 +318,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
// The report handlers read these and drop anything a stopped stream still emits.
bool target_display_enabled_{false};
bool point_cloud_enabled_{false};
bool area_presence_any_{false};
bool area_write_in_flight_{false};
bool work_mode_reported_{false};
bool low_power_enabled_{false};
bool low_power_reported_{false};
bool deferred_apply_pending_{false};
uint8_t pending_area_id_{0xFF};
AreaConfig pending_area_updates_{};
bool last_work_mode_valid_{false};
bool last_work_mode_low_power_{false};
@@ -2,8 +2,6 @@ import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
CONF_AREA_ID,
CONF_BUTTON,
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
ENTITY_CATEGORY_CONFIG,
@@ -11,22 +9,14 @@ from esphome.const import (
UNIT_MILLISECOND,
UNIT_SECOND,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_APPLY_AREA,
CONF_AREA_CONFIG,
CONF_HOLD_DELAY,
CONF_LD6002B_ID,
CONF_LOW_POWER_SLEEP_TIME,
CONF_Z_MAX,
CONF_Z_MIN,
KEY_X_MAX,
KEY_X_MIN,
KEY_Y_MAX,
KEY_Y_MIN,
)
DEPENDENCIES = ["ld6002b"]
@@ -61,83 +51,10 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_AREA_CONFIG): cv.Schema(
{
cv.Optional(KEY_X_MIN): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(KEY_X_MAX): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(KEY_Y_MIN): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(KEY_Y_MAX): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MIN): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MAX): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
}
),
}
)
def final_validate(config: ConfigType) -> ConfigType:
if config.get(CONF_AREA_CONFIG) is None:
return config
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
has_apply_area = any(
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None
for entry in full_config.get(CONF_BUTTON, [])
)
if not has_apply_area:
raise cv.Invalid(
f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance",
path=[CONF_AREA_CONFIG],
)
has_area_id_select = any(
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None
for entry in full_config.get("select", [])
)
if not has_area_id_select:
raise cv.Invalid(
f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance",
path=[CONF_AREA_CONFIG],
)
return config
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
@@ -163,19 +80,3 @@ async def to_code(config):
)
await cg.register_parented(n, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(n))
if area_config := config.get(CONF_AREA_CONFIG):
for key, number_type, setter in (
(KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"),
(KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"),
(KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"),
(KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"),
(CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"),
(CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"),
):
if conf := area_config.get(key):
n = await number.new_number(
conf, number_type, min_value=-10, max_value=10, step=0.1
)
await cg.register_parented(n, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(n))
+1 -15
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
from .. import LD6002BComponent, ld6002b_ns
from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED
@@ -11,16 +11,6 @@ DEPENDENCIES = ["ld6002b"]
LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select)
SelectType = ld6002b_ns.enum("SelectType", is_class=True)
AREA_ID_OPTIONS = [
"interference_area_0",
"interference_area_1",
"interference_area_2",
"interference_area_3",
"detection_area_0",
"detection_area_1",
"detection_area_2",
"detection_area_3",
]
CONFIG_SCHEMA = cv.Schema(
{
@@ -34,9 +24,6 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(CONF_INSTALLATION_MODE): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_AREA_ID): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
}
)
@@ -60,7 +47,6 @@ SELECT_MAP = (
"set_installation_select",
["top", "side"],
),
(CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS),
)
+8 -82
View File
@@ -12,18 +12,11 @@ from esphome.const import (
from . import LD6002BComponent
from .const import (
AREA_COUNT,
CONF_CLUSTER_ID,
CONF_DOPPLER_INDEX,
CONF_LD6002B_ID,
CONF_POINT_COUNT,
CONF_Z,
CONF_Z_MAX,
CONF_Z_MIN,
KEY_X_MAX,
KEY_X_MIN,
KEY_Y_MAX,
KEY_Y_MIN,
MAX_TARGETS,
)
@@ -75,79 +68,20 @@ TARGET_SCHEMA = cv.Schema(
}
)
AREA_SCHEMA = cv.Schema(
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(KEY_X_MIN): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
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,
),
cv.Optional(KEY_X_MAX): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(KEY_Y_MIN): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(KEY_Y_MAX): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Z_MIN): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Z_MAX): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
# (config key, C++ setter axis) for the six bounds every area sensor block carries.
_AREA_AXES = (
(KEY_X_MIN, "x_min"),
(KEY_X_MAX, "x_max"),
(KEY_Y_MIN, "y_min"),
(KEY_Y_MAX, "y_max"),
(CONF_Z_MIN, "z_min"),
(CONF_Z_MAX, "z_max"),
)
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,
),
cv.Optional(CONF_POINT_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)})
.extend(
{cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)}
)
.extend(
{cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)}
)
)
).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
async def to_code(config):
@@ -178,11 +112,3 @@ async def to_code(config):
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))
for kind in ("interference", "detection"):
for i in range(AREA_COUNT):
if area_config := config.get(f"{kind}_area_{i}"):
for key, axis in _AREA_AXES:
if axis_config := area_config.get(key):
sens = await sensor.new_sensor(axis_config)
cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens))
+3 -3
View File
@@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) {
// downstream the value is used exactly like on ESP32.
const int8_t raw = info->rssi;
memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE);
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;
@@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() {
ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC");
}
if (!have_unique_addr) {
uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0};
uint8_t wifi_mac[6] = {0};
get_mac_address_raw(wifi_mac); // MSB-first
// Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment
// the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the
@@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() {
ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored");
}
}
memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE);
memcpy(this->ble_mac_, bt_addr.addr, 6);
}
// ---------------------------------------------------------------------------
+3 -3
View File
@@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t {
/// One scan report from the controller, decoded from the SDK's rw-task event
/// (RSSI already sign-corrected).
struct BLEScanReport {
uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first)
int8_t rssi; // signed dBm (-127..+20)
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
@@ -138,7 +138,7 @@ class LN882HBLE final : public Component {
// 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_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
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)
@@ -47,7 +47,7 @@ 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"
"100ms", window_default="50ms", supports_active=True
)
@@ -129,9 +129,6 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_LN882H_BLE_TRACKER")
# Compiles the shared adv + scan-response merge (the LN controller
# delivers the pair as separate reports).
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -3,6 +3,7 @@
#include "ln882h_ble_tracker.h"
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -21,9 +22,6 @@ 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);
// Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_
// is read at each delivery to decide unclaimed-device logging.
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
// 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.
@@ -74,11 +72,19 @@ void LN882HBLETracker::loop() {
this->start_scan_();
}
}
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout. Main-task only, like every merger call.
// 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->merger_.empty())
this->merger_.sweep(now);
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_) {
@@ -139,25 +145,129 @@ void LN882HBLETracker::dump_config() {
}
// ---------------------------------------------------------------------------
// Adv/scan-response demux into the shared merger (ble_device_base): 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.
// 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->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
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 merger, so a late report would
// 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->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis());
this->stash_adv_(report);
return;
}
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
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{.address = ble_device_base::mac_lsb_first_to_uint64(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
}
// ---------------------------------------------------------------------------
@@ -246,11 +356,29 @@ void LN882HBLETracker::stop_scan_() {
// 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->merger_.flush();
this->dispatcher_.on_scan_end();
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
@@ -9,7 +9,6 @@
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/components/ln882h_ble/ln882h_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -76,10 +75,12 @@ class LN882HBLETracker : public Component,
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
this->dispatcher_.register_listener(listener);
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->dispatcher_.set_raw_advertisement_callback(callback);
this->raw_advertisement_callback_ = callback;
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The LN882H controller supports active scanning; adv + scan response arrive
@@ -90,8 +91,8 @@ class LN882HBLETracker : public Component,
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[MAC_ADDRESS_SIZE];
void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
@@ -107,11 +108,27 @@ class LN882HBLETracker : public Component,
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};
@@ -130,13 +147,45 @@ class LN882HBLETracker : public Component,
#endif
uint32_t scan_start_time_{0};
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main task (the controller queue already crossed
// tasks); the merger is clocked by millis() throughout this tracker.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
// 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
@@ -1,137 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@crnjan"]
DEPENDENCIES = ["uart"]
DOMAIN = "mitsubishi_cn105"
CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id"
CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval"
mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN)
MitsubishiCN105Component = mitsubishi_ns.class_(
"MitsubishiCN105Component",
cg.Component,
uart.UARTDevice,
)
SetRemoteTemperatureAction = mitsubishi_ns.class_(
"SetRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Component),
)
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
"ClearRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Component),
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(MitsubishiCN105Component),
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
cv.Optional(
CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s"
): cv.update_interval,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
uart.final_validate_device_schema(
DOMAIN,
require_rx=True,
require_tx=True,
data_bits=8,
parity="EVEN",
stop_bits=1,
)
)
async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID])
cg.add(var.set_parent(parent))
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
cg.add(
var.set_telemetry_request_min_interval(
config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL]
)
)
REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
)
CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component),
}
)
@automation.register_action(
f"{DOMAIN}.set_remote_temperature",
SetRemoteTemperatureAction,
REMOTE_TEMPERATURE_ACTION_SCHEMA,
synchronous=True,
)
async def remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
cg.add(var.set_temperature(temperature))
return var
@automation.register_action(
f"{DOMAIN}.clear_remote_temperature",
ClearRemoteTemperatureAction,
CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA,
synchronous=True,
)
async def clear_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -1,23 +0,0 @@
#pragma once
#include "mitsubishi_cn105_component.h"
#include "esphome/core/automation.h"
namespace esphome::mitsubishi_cn105 {
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
TEMPLATABLE_VALUE(float, temperature)
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
} // namespace esphome::mitsubishi_cn105
+55 -179
View File
@@ -1,5 +1,3 @@
import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, uart
@@ -9,248 +7,126 @@ from esphome.const import (
CONF_ID,
CONF_SUPPORTED_SWING_MODES,
CONF_TEMPERATURE,
CONF_UART_ID,
CONF_UPDATE_INTERVAL,
)
from esphome.core import CORE, ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType, TemplateArgsType
from . import (
CONF_MITSUBISHI_CN105_ID,
DOMAIN,
MITSUBISHI_CN105_DEVICE_SCHEMA,
MitsubishiCN105Component,
mitsubishi_ns,
register_mitsubishi_cn105_device,
)
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["climate"]
CODEOWNERS = ["@crnjan"]
_LOGGER = logging.getLogger(__name__)
# Deprecated legacy climate-owned hub option. Remove in 2027.2.0.
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval"
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id"
mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105")
MitsubishiCN105Climate = mitsubishi_ns.class_(
"MitsubishiCN105Climate",
climate.Climate,
cg.Component,
cg.Parented.template(MitsubishiCN105Component),
uart.UARTDevice,
)
# Legacy climate action compatibility. Remove in 2027.2.0.
LegacySetRemoteTemperatureAction = mitsubishi_ns.class_(
"LegacySetRemoteTemperatureAction",
SetRemoteTemperatureAction = mitsubishi_ns.class_(
"SetRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
# Legacy climate action compatibility. Remove in 2027.2.0.
LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_(
"LegacyClearRemoteTemperatureAction",
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
"ClearRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
def _has_top_level_hub_config() -> bool:
return DOMAIN in (CORE.raw_config or {})
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
_LOGGER.warning(
"Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is "
"deprecated. Declare '%s:' and reference it with '%s:' instead. Will "
"be removed in ESPHome 2027.2.0.",
DOMAIN,
DOMAIN,
CONF_MITSUBISHI_CN105_ID,
)
# Add the hidden hub declaration only for legacy climate-owned configs,
# so normal auto-ID resolution does not see it as a top-level hub.
config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)(
None
)
return config
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
{
cv.Optional(
CONF_SUPPORTED_SWING_MODES, default="OFF"
): validate_climate_swing_mode,
}
)
_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA)
# Hub options accepted in the legacy climate-owned configuration. When a
# top-level hub exists, leaving these on the climate is always a migration
# mistake and the generic schema error does not explain where they belong.
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
_LEGACY_HUB_KEYS = (
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL,
CONF_UART_ID,
CONF_UPDATE_INTERVAL,
)
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType:
legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config]
if not legacy_keys:
return config
keys = ", ".join(f"'{key}'" for key in legacy_keys)
message = f"{keys} must be moved under the top-level '{DOMAIN}:' block"
if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys:
message += (
f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to "
"'telemetry_request_min_interval' there"
)
raise cv.Invalid(message)
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
_LEGACY_SCHEMA = (
_BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA)
CONFIG_SCHEMA = (
climate.climate_schema(MitsubishiCN105Climate)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(
{
cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval,
cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval,
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
cv.Optional(
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s"
): cv.update_interval,
cv.Optional(
CONF_SUPPORTED_SWING_MODES, default="OFF"
): validate_climate_swing_mode,
}
)
.add_extra(_prepare_legacy_hub_config)
)
@schema_extractor("schema")
def CONFIG_SCHEMA(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
return _HUB_SCHEMA
if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config():
return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config))
return _LEGACY_SCHEMA(config)
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
def _legacy_final_validate(config: ConfigType) -> ConfigType:
if CONF_MITSUBISHI_CN105_ID in config:
return config
return uart.final_validate_device_schema(
DOMAIN,
FINAL_VALIDATE_SCHEMA = cv.All(
uart.final_validate_device_schema(
"mitsubishi_cn105",
require_rx=True,
require_tx=True,
data_bits=8,
parity="EVEN",
stop_bits=1,
)(config)
FINAL_VALIDATE_SCHEMA = _legacy_final_validate
)
)
async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
climate_config = config.copy()
# update_interval configures the protocol hub, not the climate entity.
climate_config.pop(CONF_UPDATE_INTERVAL, None)
await cg.register_component(var, climate_config)
if CONF_MITSUBISHI_CN105_ID in config:
await register_mitsubishi_cn105_device(var, config)
else:
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID])
await cg.register_component(parent, config)
await uart.register_uart_device(parent, config)
if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config:
cg.add(
parent.set_telemetry_request_min_interval(
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
)
)
cg.add(var.set_parent(parent))
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES]))
cg.add(
var.set_current_temperature_min_interval(
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
)
)
# Legacy climate action compatibility. Remove in 2027.2.0.
LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
)
# Legacy climate action compatibility. Remove in 2027.2.0.
LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
}
)
# Legacy climate action compatibility. Remove in 2027.2.0.
@automation.register_action(
f"climate.{DOMAIN}.set_remote_temperature",
LegacySetRemoteTemperatureAction,
LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA,
"climate.mitsubishi_cn105.set_remote_temperature",
SetRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
),
synchronous=True,
)
async def legacy_remote_temperature_action_to_code(
async def set_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
_LOGGER.warning(
"The 'climate.%s.set_remote_temperature' action is deprecated. Use "
"'%s.set_remote_temperature' instead. It will be removed in ESPHome "
"2027.2.0.",
DOMAIN,
DOMAIN,
)
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
cg.add(var.set_temperature(temperature))
return var
# Legacy climate action compatibility. Remove in 2027.2.0.
@automation.register_action(
f"climate.{DOMAIN}.clear_remote_temperature",
LegacyClearRemoteTemperatureAction,
LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA,
"climate.mitsubishi_cn105.clear_remote_temperature",
ClearRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
}
),
synchronous=True,
)
async def legacy_clear_temperature_action_to_code(
async def clear_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
_LOGGER.warning(
"The 'climate.%s.clear_remote_temperature' action is deprecated. Use "
"'%s.clear_remote_temperature' instead. It will be removed in ESPHome "
"2027.2.0.",
DOMAIN,
DOMAIN,
)
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -1,10 +1,8 @@
#include "mitsubishi_cn105.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
#include "mitsubishi_cn105_properties.h"
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
@@ -12,6 +10,8 @@ static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10;
static constexpr size_t HEADER_LEN = 5;
static constexpr uint8_t PREAMBLE = 0xFC;
@@ -25,11 +25,91 @@ static constexpr std::array<uint8_t, 2> CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01};
static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42;
static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62;
static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02;
static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03;
static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
constexpr bool is_valid(value_type value) const {
uint8_t raw;
return reverse_lookup(value, raw);
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
}
@@ -43,6 +123,10 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD);
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
@@ -145,8 +229,8 @@ void MitsubishiCN105::did_transition_(State to) {
case State::STATUS_UPDATED: {
if (this->pending_updates_.any() && this->is_status_initialized()) {
this->set_state_(State::APPLYING_SETTINGS);
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) {
this->current_status_msg_type_ = STATUS_MSG_TELEMETRY;
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP;
this->set_state_(State::UPDATING_STATUS);
} else {
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
@@ -180,26 +264,26 @@ void MitsubishiCN105::did_transition_(State to) {
}
}
bool MitsubishiCN105::should_request_telemetry_() const {
if (!this->is_telemetry_polling_enabled()) {
bool MitsubishiCN105::should_request_room_temperature_() const {
if (!this->is_room_temperature_enabled()) {
return false;
}
if (!this->last_telemetry_update_ms_.has_value()) {
if (!this->last_room_temperature_update_ms_.has_value()) {
return true;
}
return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_;
return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_;
}
void MitsubishiCN105::send_packet_(std::span<const uint8_t> packet) {
FrameParser::dump_buffer_vv("TX", packet.data(), packet.size());
this->device_.write_array(packet.data(), packet.size());
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
FrameParser::dump_buffer_vv("TX", packet, len);
this->device_.write_array(packet, len);
this->operation_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::update_status_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{this->current_status_msg_type_};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {this->current_status_msg_type_};
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
@@ -243,7 +327,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
if (this->is_telemetry_polling_enabled()) {
if (this->is_room_temperature_enabled()) {
changed |= previous.room_temperature != this->status_.room_temperature;
}
@@ -251,22 +335,12 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
}
bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) {
Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_};
switch (msg_type) {
case STATUS_MSG_SETTINGS:
if (!decoder.decode_settings(this->status_)) {
ESP_LOGVV(TAG, "RX settings payload too short");
return false;
}
return true;
return this->parse_status_settings_(payload, len);
case STATUS_MSG_TELEMETRY:
if (!decoder.decode_room_temperature(this->status_)) {
ESP_LOGVV(TAG, "RX telemetry payload too short");
return false;
}
this->last_telemetry_update_ms_ = get_loop_time_ms();
return true;
case STATUS_MSG_ROOM_TEMP:
return this->parse_status_room_temperature_(payload, len);
default:
ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type);
@@ -274,6 +348,54 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay
}
}
bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) {
if (len <= 10) {
ESP_LOGVV(TAG, "RX settings payload too short");
return false;
}
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
this->status_.power_on = payload[2] != 0;
}
this->use_temperature_encoding_b_ = payload[10] != 0;
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
const bool i_see = payload[3] > 0x08;
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
}
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
}
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
}
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
}
return true;
}
bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) {
if (len <= 5) {
ESP_LOGVV(TAG, "RX room temperature payload too short");
return false;
}
this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10);
this->last_room_temperature_update_ms_ = get_loop_time_ms();
return true;
}
void MitsubishiCN105::set_remote_temperature(float temperature) {
if (std::isnan(temperature)) {
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
@@ -292,12 +414,12 @@ void MitsubishiCN105::clear_remote_temperature() {
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
this->remote_temperature_half_deg_ = temperature_half_deg;
this->pending_updates_.set(Property::Temperature::Remote::ID);
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
}
void MitsubishiCN105::set_power(bool power_on) {
this->status_.power_on = power_on;
this->pending_updates_.set(Property::Power::ID);
this->pending_updates_.set(UpdateFlag::POWER);
}
void MitsubishiCN105::set_target_temperature(float target_temperature) {
@@ -306,42 +428,101 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
return;
}
this->status_.target_temperature = target_temperature;
this->pending_updates_.set(Property::Temperature::Target::ID);
this->pending_updates_.set(UpdateFlag::TEMPERATURE);
}
void MitsubishiCN105::set_mode(Mode mode) {
if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast<uint8_t>(mode));
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
return;
}
this->status_.mode = mode;
this->pending_updates_.set(UpdateFlag::MODE);
}
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
return;
}
this->status_.fan_mode = fan_mode;
this->pending_updates_.set(UpdateFlag::FAN);
}
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
return;
}
this->status_.vane_mode = vane_mode;
this->pending_updates_.set(UpdateFlag::VANE);
}
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
return;
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_};
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) {
encoder.encode_remote_temperature(this->remote_temperature_half_deg_);
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
payload[0] = 0x07;
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
}
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
} else {
encoder.encode_settings(this->status_);
payload[0] = 0x01;
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
payload[1] |= 0x10;
}
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
payload[2] |= 0x01;
if (this->set_wide_vane_high_bit_) {
payload[13] |= 0x80;
}
}
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
}
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
@@ -1,11 +1,9 @@
#pragma once
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
#include <cmath>
#include <optional>
#include <span>
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
namespace esphome::mitsubishi_cn105 {
@@ -72,16 +70,16 @@ class MitsubishiCN105 {
uint32_t get_update_interval() const { return this->update_interval_ms_; }
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; }
bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; }
void set_telemetry_request_min_interval(uint32_t interval_ms) {
this->telemetry_request_min_interval_ms_ = interval_ms;
uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; }
bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; }
void set_room_temperature_min_interval(uint32_t interval_ms) {
this->room_temperature_min_interval_ms_ = interval_ms;
}
const Status &status() const { return this->status_; }
bool is_status_initialized() const {
return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature)
: !std::isnan(this->status_.target_temperature);
return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature)
: !std::isnan(this->status_.target_temperature);
}
void set_power(bool power_on);
@@ -122,64 +120,58 @@ class MitsubishiCN105 {
uint8_t read_pos_{0};
};
enum class PropertyId : uint8_t {
enum class UpdateFlag : uint8_t {
TEMPERATURE = 0,
POWER = 1,
MODE = 2,
FAN = 3,
VANE = 4,
WIDE_VANE = 5,
REMOTE_TEMPERATURE = 6
REMOTE_TEMPERATURE = 6,
};
struct UpdateFlags {
void set(PropertyId id) { this->mask_.insert(id); }
void clear(PropertyId id) { this->mask_.erase(id); }
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
bool any() const { return !this->mask_.empty(); }
bool contains(PropertyId id) const { return this->mask_.count(id); }
bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); }
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
protected:
using Mask =
FiniteSetMask<PropertyId, DefaultBitPolicy<PropertyId, static_cast<int>(PropertyId::REMOTE_TEMPERATURE) + 1>>;
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
Mask mask_;
};
struct PropertyContext {
bool use_temperature_encoding_b{false};
bool set_wide_vane_high_bit{false};
};
friend struct Property;
void set_state_(State new_state);
void did_transition_(State to);
bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len);
bool process_status_packet_(const uint8_t *payload, size_t len);
bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len);
void send_packet_(std::span<const uint8_t> packet);
bool parse_status_settings_(const uint8_t *payload, size_t len);
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
void send_packet_(const uint8_t *packet, size_t len);
void update_status_();
bool should_request_telemetry_() const;
bool should_request_room_temperature_() const;
void apply_settings_();
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
static bool should_transition(State from, State to);
static const LogString *state_to_string(State state);
uart::UARTDevice &device_;
// Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted.
// Remove legacy note in 2027.2.0.
uint32_t update_interval_ms_{1000};
uint32_t status_update_wait_credit_ms_{0};
uint32_t operation_start_ms_{0};
// Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is
// omitted. Remove legacy note in 2027.2.0.
uint32_t telemetry_request_min_interval_ms_{60000};
std::optional<uint32_t> last_telemetry_update_ms_;
uint32_t room_temperature_min_interval_ms_{60000};
std::optional<uint32_t> last_room_temperature_update_ms_;
Status status_{};
State state_{State::NOT_CONNECTED};
UpdateFlags pending_updates_;
PropertyContext property_context_;
bool use_temperature_encoding_b_{false};
bool set_wide_vane_high_bit_{false};
FrameParser frame_parser_;
uint8_t current_status_msg_type_{0};
@@ -1,5 +1,5 @@
#include <cinttypes>
#include "mitsubishi_cn105_climate.h"
#include "esphome/core/log.h"
namespace esphome::mitsubishi_cn105 {
@@ -50,11 +50,25 @@ static constexpr std::optional<Left> reverse_map_lookup(const std::array<std::pa
return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt;
}
void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); }
void MitsubishiCN105Climate::dump_config() {
LOG_CLIMATE("", "Mitsubishi CN105 Climate", this);
if (this->hp_.is_room_temperature_enabled()) {
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
this->hp_.get_room_temperature_min_interval());
} else {
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
}
ESP_LOGCONFIG(TAG,
" Update interval: %" PRIu32 " ms\n"
" UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u",
this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(),
LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits());
}
void MitsubishiCN105Climate::setup() {
this->parent_->add_on_status_callback([this]() { this->apply_values_(); });
if (this->parent_->is_status_initialized()) {
void MitsubishiCN105Climate::setup() { this->hp_.initialize(); }
void MitsubishiCN105Climate::loop() {
if (this->hp_.update()) {
this->apply_values_();
}
}
@@ -76,7 +90,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.set_visual_max_temperature(31.0f);
traits.set_visual_temperature_step(1.0f);
if (this->parent_->is_telemetry_polling_enabled()) {
if (this->hp_.is_room_temperature_enabled()) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_current_temperature_step(0.5f);
}
@@ -86,20 +100,20 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
if (const auto target_temperature = call.get_target_temperature()) {
this->parent_->set_target_temperature(*target_temperature);
this->hp_.set_target_temperature(*target_temperature);
}
if (const auto mode = call.get_mode()) {
if (*mode == climate::CLIMATE_MODE_OFF) {
this->parent_->set_power(false);
this->hp_.set_power(false);
} else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) {
this->parent_->set_power(true);
this->parent_->set_mode(*mapped);
this->hp_.set_power(true);
this->hp_.set_mode(*mapped);
}
}
if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) {
this->parent_->set_fan_mode(*fan_mode);
this->hp_.set_fan_mode(*fan_mode);
}
if (const auto swing_mode = call.get_swing_mode()) {
@@ -126,22 +140,24 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
this->parent_->set_vane_mode(vane);
this->hp_.set_vane_mode(vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
this->parent_->set_wide_vane_mode(wide);
this->hp_.set_wide_vane_mode(wide);
}
}
this->parent_->publish_status();
if (this->hp_.is_status_initialized()) {
this->apply_values_();
}
}
void MitsubishiCN105Climate::apply_values_() {
const auto &status = this->parent_->status();
const auto &status = this->hp_.status();
this->target_temperature = status.target_temperature;
if (this->parent_->is_telemetry_polling_enabled()) {
if (this->hp_.is_room_temperature_enabled()) {
this->current_temperature = status.room_temperature;
}
@@ -1,47 +1,51 @@
#pragma once
#include "mitsubishi_cn105_component.h"
#include "mitsubishi_cn105.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice {
public:
explicit MitsubishiCN105Climate() : hp_(*this) {}
void setup() override;
void loop() override;
void dump_config() override;
climate::ClimateTraits traits() override;
void control(const climate::ClimateCall &call) override;
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
void set_supported_swing_mode(climate::ClimateSwingMode mode);
// Legacy climate action compatibility. Remove in 2027.2.0.
void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); }
void clear_remote_temperature() { this->parent_->clear_remote_temperature(); }
protected:
void apply_values_();
MitsubishiCN105 hp_;
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
};
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
};
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
@@ -1,34 +0,0 @@
#include "mitsubishi_cn105_component.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::mitsubishi_cn105 {
static const char *const TAG = "mitsubishi_cn105";
void MitsubishiCN105Component::dump_config() {
ESP_LOGCONFIG(TAG, "Mitsubishi CN105:");
if (this->hp_.is_telemetry_polling_enabled()) {
ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms",
this->hp_.get_telemetry_request_min_interval());
} else {
ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED");
}
ESP_LOGCONFIG(TAG,
" Update interval: %" PRIu32 " ms\n"
" UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u",
this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(),
LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits());
}
void MitsubishiCN105Component::setup() { this->hp_.initialize(); }
void MitsubishiCN105Component::loop() {
if (this->hp_.update()) {
this->status_callback_.call();
}
}
} // namespace esphome::mitsubishi_cn105
@@ -1,52 +0,0 @@
#pragma once
#include "mitsubishi_cn105.h"
#include "esphome/core/component.h"
#include "esphome/components/uart/uart.h"
#include <utility>
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
public:
explicit MitsubishiCN105Component() : hp_(*this) {}
void setup() override;
void loop() override;
void dump_config() override;
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
void set_power(bool power_on) { this->hp_.set_power(power_on); }
void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); }
void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); }
void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); }
void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); }
void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); }
const MitsubishiCN105::Status &status() const { return this->hp_.status(); }
bool is_status_initialized() const { return this->hp_.is_status_initialized(); }
bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); }
template<typename F> void add_on_status_callback(F &&callback) {
this->status_callback_.add(std::forward<F>(callback));
}
void publish_status() {
if (this->is_status_initialized()) {
this->status_callback_.call();
}
}
protected:
MitsubishiCN105 hp_;
CallbackManager<void()> status_callback_;
};
} // namespace esphome::mitsubishi_cn105
@@ -1,302 +0,0 @@
#pragma once
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <type_traits>
#include <utility>
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == Unknown) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
struct Property {
using PropertyId = MitsubishiCN105::PropertyId;
using Status = MitsubishiCN105::Status;
using PropertyContext = MitsubishiCN105::PropertyContext;
struct Power {
static constexpr auto ID = PropertyId::POWER;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.power_on = payload[2] != 0;
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
payload[1] |= 0x01;
payload[3] = status.power_on ? 0x01 : 0x00;
}
};
struct Temperature {
struct Target {
static constexpr auto ID = PropertyId::TEMPERATURE;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
ctx.use_temperature_encoding_b = payload[10] != 0;
}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
payload[1] |= 0x04;
if (ctx.use_temperature_encoding_b) {
payload[14] = static_cast<uint8_t>(std::round(status.target_temperature * 2.0f) + 128);
} else {
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature));
}
}
};
struct Room {
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.room_temperature = Temperature::decode(payload[2], payload[5], 10);
}
};
struct Remote {
static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE;
static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) {
if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(remote_temperature_half_deg - 16);
payload[3] = static_cast<uint8_t>(remote_temperature_half_deg + 128);
}
}
};
protected:
static constexpr float decode(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
};
template<typename Derived, auto Field> struct Lookup {
using Value = std::remove_cvref_t<decltype(std::declval<Status>().*Field)>;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx));
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
uint8_t raw;
if (Derived::MAP.reverse_lookup(status.*Field, raw)) {
Derived::encode_raw(payload, raw, ctx);
}
}
template<typename Mask> static bool validate_and_set(Value value, Status &status, Mask &mask) {
uint8_t raw;
if (!Derived::MAP.reverse_lookup(value, raw)) {
return false;
}
status.*Field = value;
mask.set(Derived::ID);
return true;
}
private:
friend Derived;
constexpr Lookup() = default;
};
struct Mode : Lookup<Mode, &Status::mode> {
static constexpr auto ID = PropertyId::MODE;
static constexpr auto MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) {
const bool i_see = payload[3] > 0x08;
return payload[3] - (i_see ? 0x08 : 0);
}
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x02;
payload[4] = raw;
}
};
struct FanMode : Lookup<FanMode, &Status::fan_mode> {
static constexpr auto ID = PropertyId::FAN;
static constexpr auto MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x08;
payload[6] = raw;
}
};
struct VaneMode : Lookup<VaneMode, &Status::vane_mode> {
static constexpr auto ID = PropertyId::VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x10;
payload[7] = raw;
}
};
struct WideVaneMode : Lookup<WideVaneMode, &Status::wide_vane_mode> {
static constexpr auto ID = PropertyId::WIDE_VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80;
}
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) {
payload[2] |= 0x01;
payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw;
}
};
template<typename Mask> struct Decoder {
const std::span<const uint8_t> payload;
PropertyContext &context;
const Mask &pending_writes;
bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) {
if (this->payload.size() <= 10) {
return false;
}
this->decode_<Power, Temperature::Target, Mode, FanMode, VaneMode, WideVaneMode>(status);
return true;
}
bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) {
if (this->payload.size() <= 5) {
return false;
}
this->decode_<Temperature::Room>(status);
return true;
}
protected:
template<typename T, typename Out> ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) {
T::decode_context(this->context, this->payload.data());
if constexpr (requires { T::ID; }) {
if (this->pending_writes.contains(T::ID)) {
return;
}
}
T::decode(out, this->payload.data(), this->context);
}
template<typename... T, typename Out> void ESPHOME_ALWAYS_INLINE decode_(Out &out) {
(this->decode_one_<T>(out), ...);
}
};
template<typename Mask> struct Encoder {
uint8_t *payload;
const PropertyContext &context;
Mask &pending_writes;
void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) {
this->payload[0] = 0x01;
this->encode_and_clear_<Power, Temperature::Target, WideVaneMode, VaneMode, Mode, FanMode>(status);
}
void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) {
this->payload[0] = 0x07;
this->encode_and_clear_<Temperature::Remote>(remote_temperature_half_deg);
}
protected:
template<typename... T, typename In> void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) {
(this->encode_one_<T>(in), ...);
(this->pending_writes.clear(T::ID), ...);
}
template<typename T, typename In> void encode_one_(const In &in) {
if (this->pending_writes.contains(T::ID)) {
T::encode(this->payload, in, this->context);
}
}
};
};
} // namespace esphome::mitsubishi_cn105
@@ -1,47 +0,0 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import (
MITSUBISHI_CN105_DEVICE_SCHEMA,
MitsubishiCN105Component,
mitsubishi_ns,
register_mitsubishi_cn105_device,
)
DEPENDENCIES = ["mitsubishi_cn105"]
CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction"
# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp.
VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"]
MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_(
"MitsubishiCN105VerticalVaneDirectionSelect",
select.Select,
cg.Component,
cg.Parented.template(MitsubishiCN105Component),
)
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema(
MitsubishiCN105VerticalVaneDirectionSelect,
icon="mdi:arrow-up-down",
),
}
).extend(MITSUBISHI_CN105_DEVICE_SCHEMA)
async def to_code(config: ConfigType) -> None:
if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION):
var = cg.new_Pvariable(vertical_vane_direction[CONF_ID])
await cg.register_component(var, vertical_vane_direction)
await select.register_select(
var,
vertical_vane_direction,
options=VERTICAL_VANE_DIRECTIONS,
)
await register_mitsubishi_cn105_device(var, config)
@@ -1,39 +0,0 @@
#include "mitsubishi_cn105_vane_select_vertical.h"
#include <array>
namespace esphome::mitsubishi_cn105 {
// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py.
// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based
// Select API, so Python option order and this array must stay aligned.
static constexpr std::array VALUES{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2,
MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5,
MitsubishiCN105::VaneMode::SWING,
};
void MitsubishiCN105VerticalVaneDirectionSelect::setup() {
this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); });
if (this->parent_->is_status_initialized()) {
this->publish_vane_state(this->parent_->status().vane_mode);
}
}
void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) {
if (index < VALUES.size()) {
this->parent_->set_vane_mode(VALUES[index]);
this->parent_->publish_status();
}
}
void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) {
for (size_t i = 0; i < VALUES.size(); ++i) {
if (VALUES[i] == mode) {
this->publish_state(i);
return;
}
}
}
} // namespace esphome::mitsubishi_cn105
@@ -1,21 +0,0 @@
#pragma once
#include "../mitsubishi_cn105_component.h"
#include "esphome/components/select/select.h"
#include "esphome/core/component.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void publish_vane_state(MitsubishiCN105::VaneMode mode);
protected:
void control(size_t index) override;
};
} // namespace esphome::mitsubishi_cn105
-1
View File
@@ -28,7 +28,6 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000
MAX_NUM_OF_COILS_TO_WRITE = 1968
MAX_NUM_OF_REGISTERS_TO_READ = 125
MAX_NUM_OF_REGISTERS_TO_WRITE = 123
MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121
modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
+47 -198
View File
@@ -1,7 +1,4 @@
#include "modbus.h"
#include <algorithm>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -381,9 +378,10 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
return nullptr;
}
ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) {
if (!helpers::address_range_fits(start_address, count)) {
ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count);
ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) {
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
number_of_registers);
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
return std::nullopt;
@@ -396,11 +394,6 @@ static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2;
static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5;
// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1).
static constexpr size_t READ_WRITE_VALUES_OFFSET = 9;
// A coil write (FC 0x0F) is function(1) + start(2) + quantity(2) + byte count(1) + packed bits. The largest
// one (MAX_NUM_OF_COILS_TO_WRITE coils) must fit the received request PDU, so the value subspan taken at
// WRITE_MULTIPLE_VALUES_OFFSET can never run past it.
static_assert(1 + WRITE_MULTIPLE_VALUES_OFFSET + packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE) <= MAX_PDU_SIZE,
"the largest FC 0x0F coil write must fit within MAX_PDU_SIZE");
ResponseStatus ModbusServerHub::parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers) {
@@ -420,59 +413,13 @@ ResponseStatus ModbusServerHub::parse_write_multiple_(std::span<const uint8_t> d
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
if (ResponseStatus status = this->check_address_range_(start_address, number_of_registers); status.has_value()) {
if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) {
return status;
}
this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers);
return std::nullopt;
}
ResponseStatus ModbusServerHub::parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities,
const LogString *entity_name, uint16_t &start_address,
uint16_t &count) {
// Every read request is start address(2) + quantity(2); only the protocol ceiling differs per function
// code, so registers and coils/discrete inputs validate through here and cannot drift apart.
start_address = helpers::get_data<uint16_t>(data.data(), 0);
count = helpers::get_data<uint16_t>(data.data(), 2);
if (count == 0 || count > max_entities) {
ESP_LOGW(TAG, "Invalid number of %s %" PRIu16, LOG_STR_ARG(entity_name), count);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
return this->check_address_range_(start_address, count);
}
ResponseStatus ModbusServerHub::parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address,
bool &value) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
const uint16_t raw_value = helpers::get_data<uint16_t>(data.data(), WRITE_SINGLE_VALUES_OFFSET);
if (raw_value != 0xFF00 && raw_value != 0x0000) {
ESP_LOGW(TAG, "Invalid coil value 0x%04X", raw_value);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
// No range check needed: one coil can never push start_address + 1 past the address space.
value = raw_value == 0xFF00;
return std::nullopt;
}
ResponseStatus ModbusServerHub::parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address,
uint16_t &count, std::span<const uint8_t> &packed_bytes) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
const uint16_t number_of_bits = helpers::get_data<uint16_t>(data.data(), 2);
const uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 4);
if (number_of_bits == 0 || number_of_bits > MAX_NUM_OF_COILS_TO_WRITE ||
packed_bit_bytes(number_of_bits) != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of coils %" PRIu16 " or bytes %" PRIu8, number_of_bits, number_of_bytes);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
if (ResponseStatus status = this->check_address_range_(start_address, number_of_bits); status.has_value()) {
return status;
}
count = number_of_bits;
// coil values follow start(2) + quantity(2) + byte count(1)
packed_bytes = data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes);
return std::nullopt;
}
void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers) {
for (size_t offset = 0; offset + 1 < values.size(); offset += 2) {
registers.push_back(helpers::get_data<uint16_t>(values.data(), offset));
@@ -480,16 +427,11 @@ void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, Regis
}
void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data) {
// Broadcasts are only meaningful for writes and are never answered (Modbus 4.1 / 6.12), so an unsupported
// function code or a validation failure is silently dropped instead of replying with an exception. Both
// register writes (FC 0x06/0x10) and coil writes (FC 0x05/0x0F) are broadcastable by spec, and each shares
// its parser with the addressed path so a broadcast is validated exactly as the unicast form would be.
// Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an
// unsupported function code or a validation failure is silently dropped instead of replying with an exception.
// Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet.
uint16_t start_address;
RegisterValues registers;
uint16_t coil_count = 0;
std::span<const uint8_t> packed_bytes;
uint8_t single_bit = 0; // backs packed_bytes for a single-coil write, so it must outlive the loop below
bool coils = false;
ResponseStatus status;
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::WRITE_SINGLE_REGISTER:
@@ -498,19 +440,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
status = this->parse_write_multiple_(data, start_address, registers);
break;
case FunctionCode::WRITE_SINGLE_COIL: {
coils = true;
bool value = false;
status = this->parse_write_single_coil_(data, start_address, value);
single_bit = value ? 0x01 : 0x00;
coil_count = 1;
packed_bytes = std::span<const uint8_t>(&single_bit, 1);
break;
}
case FunctionCode::WRITE_MULTIPLE_COILS:
coils = true;
status = this->parse_write_multiple_coils_(data, start_address, coil_count, packed_bytes);
break;
default:
// Reads and read/write require a reply, so they are not valid as broadcasts.
ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code);
@@ -523,12 +452,8 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
// per-device outcome at V, and warn if the write reached nobody at all.
bool accepted = false;
for (auto *device : this->devices_) {
// Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need
// to: the hub owns the difference, which is only that no reply is ever sent.
const ResponseStatus device_status =
coils ? device->on_write_coils(start_address, PackedBits(packed_bytes, coil_count))
: device->on_write_registers(start_address, registers);
if (device_status.has_value()) {
if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers);
device_status.has_value()) {
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
static_cast<uint8_t>(device_status.value()));
} else {
@@ -536,19 +461,15 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
}
}
if (!accepted && !this->devices_.empty()) {
const uint16_t entity_count = coils ? coil_count : static_cast<uint16_t>(registers.size());
const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers");
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
// repeats forever, so warning per frame would flood the log.
const uint32_t now = millis();
if (this->last_unaccepted_broadcast_warn_ == 0 ||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
this->last_unaccepted_broadcast_warn_ = now;
ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
} else {
ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
LOG_STR_ARG(entity_name), start_address);
ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
}
}
}
@@ -558,7 +479,8 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu
std::span<uint8_t> response_buffer, uint16_t &response_len) {
// A handler that returns an exception leaves registers partially filled, so check the exception
// first and forward it before validating the register count on the success path.
if (this->rejected_(address, function_code, status)) {
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return false;
}
@@ -613,11 +535,17 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
uint16_t start_address;
uint16_t number_of_registers;
status = this->parse_read_request_(data, MAX_NUM_OF_REGISTERS_TO_READ, LOG_STR("registers"), start_address,
number_of_registers);
if (this->rejected_(address, function_code, status)) {
// PDU data: start address(2) + quantity(2).
uint16_t start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
status = this->check_register_range_(start_address, number_of_registers);
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
RegisterValues registers;
@@ -643,7 +571,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
} else {
status = this->parse_write_multiple_(data, start_address, registers);
}
if (this->rejected_(address, function_code, status)) {
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
status = device->on_write_registers(start_address, registers);
@@ -651,64 +580,6 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
response_len = 4;
break;
}
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS: {
uint16_t start_address;
uint16_t number_of_bits;
status =
this->parse_read_request_(data, MAX_NUM_OF_COILS_TO_READ, LOG_STR("bits"), start_address, number_of_bits);
if (this->rejected_(address, function_code, status)) {
return;
}
// Response: byte count(1) + packed bytes, written straight into the pre-zeroed response buffer. It
// always fits: the parse above caps the count, and a static_assert bounds that against MAX_RAW_SIZE.
const uint8_t byte_count = static_cast<uint8_t>(packed_bit_bytes(number_of_bits));
response_buffer[response_len++] = byte_count;
// Take the packed-bytes span off a span that knows response_buffer's real size, so a future non-zero
// response_len (e.g. a prefix written before the packed data) is a bounds error, not a silent overrun.
std::span<uint8_t> packed_out = std::span<uint8_t>(response_buffer).subspan(response_len, byte_count);
std::fill(packed_out.begin(), packed_out.end(), 0);
MutablePackedBits bits(packed_out, number_of_bits);
if (static_cast<FunctionCode>(function_code) == FunctionCode::READ_COILS) {
status = device->on_read_coils(start_address, bits);
} else {
status = device->on_read_discrete_inputs(start_address, bits);
}
if (this->rejected_(address, function_code, status)) {
return;
}
response_len += byte_count;
break;
}
case FunctionCode::WRITE_SINGLE_COIL: {
// A single coil is handed to the device as a one-bit packed view, the same form a multiple-coil
// write takes, so a device only ever implements one coil write handler.
uint16_t start_address;
bool value = false;
status = this->parse_write_single_coil_(data, start_address, value);
if (this->rejected_(address, function_code, status)) {
return;
}
const uint8_t single_bit = value ? 0x01 : 0x00;
status = device->on_write_coils(start_address, PackedBits(std::span<const uint8_t>(&single_bit, 1), 1));
response_data = data.data(); // echo the request header per Modbus 6.5, 6.11
response_len = 4;
break;
}
case FunctionCode::WRITE_MULTIPLE_COILS: {
// Parse and validate the coil write PDU into a packed-bit view; reply with an exception on failure.
uint16_t start_address;
uint16_t count;
std::span<const uint8_t> packed_bytes;
status = this->parse_write_multiple_coils_(data, start_address, count, packed_bytes);
if (this->rejected_(address, function_code, status)) {
return;
}
status = device->on_write_coils(start_address, PackedBits(packed_bytes, count));
response_data = data.data(); // echo the request header per Modbus 6.5, 6.11
response_len = 4;
break;
}
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
// PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) +
// write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read.
@@ -725,11 +596,12 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
status = this->check_address_range_(read_start_address, number_of_registers);
status = this->check_register_range_(read_start_address, number_of_registers);
if (!status.has_value()) {
status = this->check_address_range_(write_start_address, number_of_write_registers);
status = this->check_register_range_(write_start_address, number_of_write_registers);
}
if (this->rejected_(address, function_code, status)) {
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
// Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read
@@ -742,7 +614,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
// from the values it just stored.
status = device->on_write_registers(write_start_address, write_registers);
}
if (this->rejected_(address, function_code, status)) {
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
RegisterValues registers;
@@ -759,7 +632,9 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION);
return;
}
if (!this->rejected_(address, function_code, status)) {
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
} else {
this->send_response_(address, function_code, response_data, response_len);
}
}
@@ -858,19 +733,6 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con
this->send_raw_(raw_frame, payload_len + 2);
}
bool ModbusServerHub::rejected_(uint8_t address, uint8_t function_code, ResponseStatus status) {
if (!status.has_value())
return false;
// The one place a rejection becomes an exception reply, so the log carries the transaction context a
// device handler never has: which client-facing address and function code drew which exception. DEBUG
// rather than WARN because an exception reply is a normal protocol outcome and arrives per frame - a
// probing or broken client would otherwise flood the log. The parse helpers still WARN with specifics.
ESP_LOGD(TAG, "Exception %" PRIu8 " replied to function 0x%02X for address %" PRIu8,
static_cast<uint8_t>(status.value()), function_code, address);
this->send_exception_(address, function_code, status.value());
return true;
}
void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) {
uint8_t raw_frame[3];
raw_frame[0] = address;
@@ -1021,8 +883,8 @@ void ModbusClientHub::sweep_() {
}
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device,
CommandOptions options) {
bool ModbusClientHub::send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device,
CommandOptions options) {
// Requests refused here never enter the machine and get no callback - the false return is it.
if (pdu.empty()) {
ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address);
@@ -1056,8 +918,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
continue;
if (device == nullptr) {
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
const bool requeueable =
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]);
if (requeueable) {
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
} else {
@@ -1134,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClient
ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused");
return;
}
this->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
this->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
}
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
@@ -1216,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
// - On failure (status engaged) the response is empty by design (see on_error()), so only the request
// is validated.
bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size());
if (!custom && succeeded(status)) {
if (!custom && !status.has_value()) {
custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size());
if (!custom && helpers::is_function_code_read(static_cast<uint8_t>(function_code))) {
const bool bits =
@@ -1237,34 +1098,22 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
switch (function_code) {
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
// FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a
// plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its
// response carries only that read data, and the write half is confirmed by the response arriving at all.
// An exception routes here as well (the gate only validates the request when status is set), delivering
// empty registers with the error in status - so a 0x17 subclass handles success and failure in the one
// on_read_holding_registers() callback and never needs to also override on_error().
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
case FunctionCode::READ_INPUT_REGISTERS: {
// Decode the big-endian register words into host byte order. The gate guarantees a success response
// carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the
// capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On
// failure the registers span is empty.
RegisterValues registers;
if (succeeded(status)) {
if (!status.has_value()) {
for (size_t i = 0; i != count_or_value; i++) {
registers.push_back(helpers::get_data<uint16_t>(response_pdu.data(), 2 + 2 * i));
}
}
std::span<const uint16_t> register_span(registers.data(), registers.size());
if (function_code == FunctionCode::READ_INPUT_REGISTERS) {
this->on_read_input_registers(start_address, register_span, status);
} else if (function_code == FunctionCode::READ_HOLDING_REGISTERS ||
function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
if (function_code == FunctionCode::READ_HOLDING_REGISTERS) {
this->on_read_holding_registers(start_address, register_span, status);
} else {
// Unreachable for the current case labels; match explicitly so a function code added to this group
// later is diverted to on_custom_response() rather than silently delivered as a holding read.
this->on_custom_response(request_pdu, response_pdu, status);
this->on_read_input_registers(start_address, register_span, status);
}
break;
}
@@ -1275,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
// PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them.
std::span<const uint8_t> packed_bytes;
uint16_t count = 0;
if (succeeded(status)) {
if (!status.has_value()) {
packed_bytes = response_pdu.subspan(2);
count = count_or_value;
}
@@ -1292,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
// copy. On an exception the response has no value and the request copy is the only one.
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_SINGLE_COIL: {
const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE)
const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE)
? helpers::get_data<uint16_t>(response_pdu.data(), 3)
: count_or_value;
if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) {
+52 -116
View File
@@ -151,7 +151,9 @@ struct ModbusDeviceCommand {
static CommandPriority classify(uint8_t function_code) {
if (helpers::is_function_code_exception(function_code))
return CommandPriority::READ;
if (helpers::is_function_code_write(function_code)) {
const auto code = static_cast<FunctionCode>(function_code);
if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER ||
code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
return CommandPriority::WRITE;
}
return CommandPriority::READ;
@@ -160,7 +162,7 @@ struct ModbusDeviceCommand {
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc);
return (requeueable && !this->continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
@@ -260,31 +262,19 @@ class ModbusClientHub : public Modbus {
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
bool tx_buffer_empty();
bool tx_blocked() override;
ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
this->queue_pdu(address,
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
payload_len),
device);
this->send_pdu(address,
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
payload_len),
device);
};
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in
/// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {});
// Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions:
// the bool return and the options argument arrived after that release, so nothing external can be
// relying on them under this name. Callers who want the queued/refused answer move to queue_pdu().
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
"reports whether the request was accepted. Removed in 2027.2.0",
"2026.8.0")
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
this->queue_pdu(address, pdu, device);
}
ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
// Queue a request; true once it is a live entry (resolving in one terminal), false if it never
// entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback.
bool send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {});
ESPDEPRECATED("Use send_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently.
@@ -325,12 +315,6 @@ class ModbusClientHub : public Modbus {
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
/// True when a transaction carried no exception. The optional holds the exception, so has_value() means
/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the
/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code
/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer.
inline bool succeeded(ResponseStatus status) { return !status.has_value(); }
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
@@ -359,27 +343,9 @@ class ModbusServerHub : public Modbus {
// Appends the big-endian register values in values to registers, in host byte order.
void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers);
ModbusServerDevice *find_device_(uint8_t address);
// Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space,
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast
// write is never answered, so the check cannot send it itself. Shared by the register and
// coil/discrete-input handlers, which all address the same 16-bit space.
ResponseStatus check_address_range_(uint16_t start_address, uint16_t count);
// Parses a read request PDU (start address(2) + quantity(2)), shared by the register and
// coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the
// function code; entity_name only labels the rejection log.
ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name,
uint16_t &start_address, uint16_t &count);
// Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed
// bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take.
ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value);
// Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive
// buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and
// broadcast paths so the two validate identically.
ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count,
std::span<const uint8_t> &packed_bytes);
// Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space,
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required.
ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers);
// Builds the body of a register read response (byte count followed by the big-endian register values) into
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
@@ -390,9 +356,6 @@ class ModbusServerHub : public Modbus {
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len);
void send_raw_(const uint8_t *payload, uint16_t len);
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
// Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart.
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0};
@@ -410,7 +373,7 @@ class ModbusServerHub : public Modbus {
/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data),
/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by
/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return)
/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return)
/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request.
/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all
/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from
@@ -420,7 +383,7 @@ class ModbusServerHub : public Modbus {
/// merges into it).
///
/// Invariants:
/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
/// entry through its callback-free transition methods.
/// - Public entry points can never trigger a callback synchronously.
/// - Callbacks are delivered only from within loop().
@@ -522,85 +485,66 @@ class ModbusClientDevice {
/// to handle custom traffic (which also silences the warning).
virtual void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status);
ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0")
ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0")
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
const uint8_t *payload = nullptr) {
this->parent_->queue_pdu(
this->parent_->send_pdu(
this->address_,
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
this);
}
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will
/// follow, false = refused at the door and nothing further happens. Neither means the frame is on
/// the wire; on_sent() reports that.
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
return this->parent_->queue_pdu(this->address_, pdu, this, options);
/// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow),
/// false = refused at the door (no callback).
bool send_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
return this->parent_->send_pdu(this->address_, pdu, this, options);
}
// Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options.
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
"reports whether the request was accepted. Removed in 2027.2.0",
"2026.8.0")
void send_pdu(std::span<const uint8_t> pdu) { this->queue_pdu(pdu); }
ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload) {
ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
bool send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty())
return; // too short to contain a PDU; refused at the door like any invalid send
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
return false; // too short to contain a PDU; refused at the door like any invalid send
return this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
// The typed request builders below all queue through queue_pdu(), so they share its contract: true
// means the request is queued and will resolve in exactly one terminal callback, false means it was
// refused outright with no callback. Neither says the frame has been transmitted - on_sent() does.
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
// create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return.
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
CommandOptions options = {}) {
return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
number_of_entities),
options);
return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
number_of_entities),
options);
}
bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
return this->queue_pdu(
return this->send_pdu(
helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options);
}
bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
return this->queue_pdu(
return this->send_pdu(
helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options);
}
bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) {
return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options);
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options);
}
bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) {
return this->queue_pdu(
helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options);
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs),
options);
}
bool write_single_register(uint16_t start_address, uint16_t value) {
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value));
return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value));
}
bool write_single_coil(uint16_t address, bool value) {
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
return this->send_pdu(helpers::create_write_single_coil_pdu(address, value));
}
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
return this->send_pdu(helpers::create_write_registers_pdu(start_address, values));
}
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
/// overload.
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values));
return this->send_pdu(helpers::create_write_coils_pdu(start_address, values));
}
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
/// read-modify-write needs no unpack/repack.
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the
/// read registers, the same wire shape as a holding-register read). A device exception - typically a
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status,
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
/// needs to also override on_error().
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
std::span<const uint16_t> write_values) {
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
write_start_address, write_values));
return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
@@ -673,26 +617,18 @@ class ModbusServerDevice {
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues &registers) {
return ExceptionCode::ILLEGAL_FUNCTION;
};
/// Coil/discrete-input reads: set the requested bits (bit 0 = the coil at start_address) with
/// bits.set(). The view covers bits.size() pre-zeroed bits and writes land directly in the hub's
/// response buffer (no copy); it is only valid during the call.
virtual ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) {
return ExceptionCode::ILLEGAL_FUNCTION;
};
virtual ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) {
return this->on_read_bits(start_address, bits);
};
virtual ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) {
return this->on_read_bits(start_address, bits);
};
/// Coil writes deliver the values as a PackedBits view over the hub's receive buffer (only valid
/// during the call). A single-coil write (FC 0x05) arrives as bits.size() == 1.
virtual ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) {
return ExceptionCode::ILLEGAL_FUNCTION;
};
// Hub entry point for broadcast (address 0) writes, which are never answered.
ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues &registers) {
this->broadcast_write_ = true;
ResponseStatus status = this->on_write_registers(start_address, registers);
this->broadcast_write_ = false;
return status;
}
protected:
uint8_t address_{0};
// Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence.
bool broadcast_write_{false};
};
} // namespace esphome::modbus
@@ -128,19 +128,6 @@ static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload m
/// Bits pack 8 per data byte, rounded up to whole bytes.
constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; }
// A coil/discrete-input read answers with byte count(1) + packed_bit_bytes(count) bytes, which has to fit
// the raw frame body. The runtime check on that path catches a caller entering with bytes already written;
// this catches the other way in, raising the ceiling past what a frame can carry.
static_assert(1 + packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ) <= MAX_RAW_SIZE,
"MAX_NUM_OF_COILS_TO_READ yields a read response larger than MAX_RAW_SIZE");
static_assert(1 + packed_bit_bytes(MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) <= MAX_RAW_SIZE,
"MAX_NUM_OF_DISCRETE_INPUTS_TO_READ yields a read response larger than MAX_RAW_SIZE");
// The coil and discrete-input ceilings are separate limits in the spec but hold the same value, so the
// read paths validate both against MAX_NUM_OF_COILS_TO_READ. Should the spec ever split them, this fires.
static_assert(MAX_NUM_OF_COILS_TO_READ == MAX_NUM_OF_DISCRETE_INPUTS_TO_READ,
"the coil and discrete-input read ceilings must match");
/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout
* coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the
* two cannot desynchronize. The view does not own the bytes - it is only valid while they are.
+34 -66
View File
@@ -8,11 +8,10 @@ namespace esphome::modbus::helpers {
static const char *const TAG = "modbus_helpers";
// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum,
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space.
// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a
// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed.
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space
// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass).
static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) {
return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity);
return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u;
}
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and
@@ -70,10 +69,8 @@ uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
case FunctionCode::WRITE_SINGLE_REGISTER:
return 5; // function(1) + output/register address(2) + value(2)
case FunctionCode::WRITE_MULTIPLE_COILS:
// function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte).
return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0);
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
// function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register).
// function(1) + start address(2) + quantity(2) + byte count(1) + data
return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case FunctionCode::READ_FILE_RECORD:
@@ -308,20 +305,16 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
// Append a 16-bit value to a PDU in big-endian (wire) byte order.
template<size_t CAP> static void append_pdu_word(StaticVector<uint8_t, CAP> &pdu, uint16_t value) {
pdu.push_back(value >> 8);
pdu.push_back(value >> 0);
}
// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit
// fields (start address + quantity for reads and multi-writes, address + value for single writes).
template<size_t CAP>
static void append_pdu_header(StaticVector<uint8_t, CAP> &pdu, FunctionCode function_code, uint16_t first,
uint16_t second) {
pdu.push_back(static_cast<uint8_t>(function_code));
append_pdu_word(pdu, first);
append_pdu_word(pdu, second);
pdu.push_back(first >> 8);
pdu.push_back(first >> 0);
pdu.push_back(second >> 8);
pdu.push_back(second >> 0);
}
// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one
@@ -340,7 +333,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return pdu;
}
if (!address_range_fits(start_address, number_of_entities)) {
if (uint32_t(start_address) + number_of_entities > 0x10000u) {
ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
@@ -383,7 +376,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
if (is_function_code_read_only(static_cast<uint8_t>(function_code))) {
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
static_cast<uint8_t>(function_code));
@@ -422,7 +415,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
static_cast<uint8_t>(function_code));
return pdu;
}
if (!is_single && !address_range_fits(start_address, number_of_entities)) {
if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) {
ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
@@ -465,59 +458,29 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
return pdu;
}
// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not
// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure
// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in
// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each
// failure names its reason, and taking size_t so an oversize span is caught before any narrowing.
static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity,
uint16_t max_quantity) {
if (quantity == 0 || quantity > max_quantity) {
ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity);
return false;
}
if (!address_range_fits(start_address, quantity)) {
ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role),
quantity, start_address);
return false;
}
return true;
}
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) {
if (values.empty()) {
ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request");
return pdu;
}
// Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE.
if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE);
return pdu;
}
if (uint32_t(start_address) + values.size() > 0x10000u) {
ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(),
start_address);
return pdu;
}
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
pdu.push_back(static_cast<uint8_t>(values.size() * 2)); // byte count
for (auto v : values) {
append_pdu_word(pdu, v);
}
return pdu;
}
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address,
std::span<const uint16_t> write_values) {
PduBuffer pdu;
if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) {
return pdu;
}
if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) {
return pdu;
}
// fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values.
const auto write_count = static_cast<uint16_t>(write_values.size());
pdu.push_back(static_cast<uint8_t>(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS));
append_pdu_word(pdu, read_start_address);
append_pdu_word(pdu, read_count);
append_pdu_word(pdu, write_start_address);
append_pdu_word(pdu, write_count);
pdu.push_back(static_cast<uint8_t>(write_count * 2)); // byte count
for (auto v : write_values) {
append_pdu_word(pdu, v);
auto decoded_value = decode_value(v);
pdu.push_back(decoded_value[0]);
pdu.push_back(decoded_value[1]);
}
return pdu;
}
@@ -547,7 +510,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed
ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE);
return;
}
if (!address_range_fits(start_address, count)) {
if (uint32_t(start_address) + count > 0x10000u) {
ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address);
return;
}
@@ -583,7 +546,12 @@ static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const
return pdu;
}
CoilPackBuffer packed;
pack_bits(packed, values);
for (size_t i = 0; i != count; i++) {
if (i % 8 == 0)
packed.push_back(0);
if (values[i])
packed[i / 8] |= (1 << (i % 8));
}
build_write_coils_pdu(pdu, start_address, PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), count));
return pdu;
}
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more