mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
171
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13704a148e | ||
|
|
ebb0923362 | ||
|
|
e1c279718f | ||
|
|
cf764740cf | ||
|
|
58d549ed4c | ||
|
|
6b22d8068d | ||
|
|
443d8f1f28 | ||
|
|
1ec21a2245 | ||
|
|
bb7d4c3630 | ||
|
|
f42fe9af29 | ||
|
|
0bc2d71370 | ||
|
|
594c12b3d9 | ||
|
|
bca72e9b6d | ||
|
|
ce09504c92 | ||
|
|
dda4566b9e | ||
|
|
46a5665a66 | ||
|
|
9161f74bb1 | ||
|
|
3f01f9895f | ||
|
|
801a1817b5 | ||
|
|
32c76ae828 | ||
|
|
c664f5fc95 | ||
|
|
2bc4681fd6 | ||
|
|
646501b0ef | ||
|
|
de3e657d8b | ||
|
|
1add726892 | ||
|
|
5a000cf5e4 | ||
|
|
6ed676fe32 | ||
|
|
7cceddb8a3 | ||
|
|
039b897e7b | ||
|
|
b178f74e5d | ||
|
|
e5224e22ae | ||
|
|
be66e8b99c | ||
|
|
4b91c8aff4 | ||
|
|
9d7997f55a | ||
|
|
4db47de556 | ||
|
|
c8de632764 | ||
|
|
b794b7b1d1 | ||
|
|
add18d4e35 | ||
|
|
02c1810c3a | ||
|
|
4f3153375a | ||
|
|
1c3a67b5e8 | ||
|
|
236ff33a09 | ||
|
|
7c07fb48c5 | ||
|
|
d72bab79d7 | ||
|
|
617e2ec1e0 | ||
|
|
1afac0312d | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
9c3407cfdd | ||
|
|
d3e27054f6 | ||
|
|
bd58b5c8b3 | ||
|
|
c1a326f32e | ||
|
|
a14ea0e8fa | ||
|
|
48d6368ff9 | ||
|
|
83cff59fdd | ||
|
|
89489b1f0d | ||
|
|
7569a7b5ce | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
ec6b4263a2 | ||
|
|
8a1aa5753d | ||
|
|
3e4661fe1e | ||
|
|
22153be4cd | ||
|
|
3f490fe1ed | ||
|
|
58a42fe5c2 | ||
|
|
25c0c2c97b | ||
|
|
622942482c | ||
|
|
cffd775450 | ||
|
|
8d87ba34d9 | ||
|
|
08c6585915 | ||
|
|
3ef74d17af | ||
|
|
5d04c1dc18 | ||
|
|
a3d599ac69 | ||
|
|
a4e05cd1c8 | ||
|
|
1758653330 | ||
|
|
a2feff8f68 | ||
|
|
37eae9b466 | ||
|
|
9605b34c69 | ||
|
|
bab62b345b | ||
|
|
3349046c5d | ||
|
|
3f5b8139f3 | ||
|
|
be5e28ea9e | ||
|
|
9f28638ee7 | ||
|
|
201f843e95 | ||
|
|
37a59a07bc | ||
|
|
9556c2bc4c | ||
|
|
3540012529 | ||
|
|
e0b112c584 | ||
|
|
7f0d6a8696 | ||
|
|
4a7de87bff | ||
|
|
b9d1d2f06b | ||
|
|
74c30c62ef | ||
|
|
94fbfa05de | ||
|
|
55bd63732d | ||
|
|
3ae651af7b | ||
|
|
55e8bc3b14 | ||
|
|
9938a2487d | ||
|
|
aee41d64c2 | ||
|
|
f3a5a9fbd5 | ||
|
|
8ee3c8d41d | ||
|
|
eefd2a00c7 | ||
|
|
e8852c5950 | ||
|
|
069f40f653 | ||
|
|
04dd6b3a55 | ||
|
|
2ff55e3058 | ||
|
|
e5cdda9ee5 | ||
|
|
ecec3a19c7 | ||
|
|
9823ad6abc | ||
|
|
51b0240fb8 | ||
|
|
f697cf2011 | ||
|
|
8728aaa616 | ||
|
|
eab9a47aa2 | ||
|
|
c2ba2fbfc4 | ||
|
|
3de01d2a2e | ||
|
|
791d6659c0 | ||
|
|
007c677da1 | ||
|
|
2a0f2d59f0 | ||
|
|
c8d2c3691a | ||
|
|
82a63658f9 | ||
|
|
596827c51c | ||
|
|
8a16ead8ce | ||
|
|
334afdefb5 | ||
|
|
e7a2980b9c | ||
|
|
b5a78c6c46 | ||
|
|
8650d175f4 | ||
|
|
2999e7b925 | ||
|
|
207ae2e4cb | ||
|
|
9e78a768a2 | ||
|
|
e4d08a73b0 | ||
|
|
4cf7ad9c63 | ||
|
|
293d0b90d9 | ||
|
|
8f1e439792 | ||
|
|
ad733272e5 | ||
|
|
b7c6245388 | ||
|
|
2184ec2928 | ||
|
|
bae7f19323 | ||
|
|
56ec21d950 | ||
|
|
2798ef4de2 | ||
|
|
e90b4abe9c | ||
|
|
f3d1fc0d64 | ||
|
|
c8c929d487 | ||
|
|
d9567b2974 | ||
|
|
02fa18b74f | ||
|
|
3656375516 | ||
|
|
b0a9bfd381 | ||
|
|
25cb440005 | ||
|
|
0f59ef36a9 | ||
|
|
3de8c7f95c | ||
|
|
989dbd7550 | ||
|
|
ab12e5490f | ||
|
|
e9f428983e |
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== 'dev') {
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack } = {}) {
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request }
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -179,6 +179,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
script/ci-custom.py
|
||||
script/build_codeowners.py --check
|
||||
script/build_alias_registry.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2-boards.py --check
|
||||
@@ -444,8 +445,12 @@ jobs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
github.repository == 'esphome/esphome' && (
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
)
|
||||
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -466,7 +471,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
|
||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
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}"
|
||||
@@ -1,12 +1,23 @@
|
||||
---
|
||||
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
|
||||
@@ -23,6 +34,8 @@ 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
|
||||
@@ -34,12 +47,23 @@ 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
|
||||
|
||||
@@ -57,6 +57,12 @@ 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:
|
||||
|
||||
@@ -238,6 +238,7 @@ 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
|
||||
@@ -465,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.8.0-dev
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+219
-82
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
@@ -21,7 +21,6 @@ from esphome.const import (
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
@@ -29,6 +28,7 @@ from esphome.const import (
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -42,7 +42,7 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
@@ -71,6 +71,9 @@ from esphome.util import (
|
||||
safe_print,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import threading
|
||||
|
||||
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
|
||||
# module's top level. Every `esphome` invocation — including fast paths
|
||||
# like `esphome version` — pays the cost of what's imported here before
|
||||
@@ -273,8 +276,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
|
||||
if purpose == Purpose.LOGGING and not has_api():
|
||||
return (
|
||||
"Cannot view logs over the network: no 'api:' component is "
|
||||
"configured. Network log streaming requires the native API; add "
|
||||
"an 'api:' component, enable MQTT logging, or view logs over USB."
|
||||
"configured. Add an 'api:' component, enable MQTT logging, add a "
|
||||
"'web_server:' component, or view logs over USB."
|
||||
)
|
||||
if purpose == Purpose.UPLOADING and not has_ota():
|
||||
return (
|
||||
@@ -314,9 +317,12 @@ def choose_upload_log_host(
|
||||
]
|
||||
resolved.append(choose_prompt(options, purpose=purpose))
|
||||
elif device == "OTA":
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and has_api())
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
):
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
@@ -328,7 +334,11 @@ def choose_upload_log_host(
|
||||
if has_mqtt_logging():
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
if (
|
||||
network_logging
|
||||
and has_non_ip_address()
|
||||
and has_resolvable_address()
|
||||
):
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
@@ -390,7 +400,7 @@ def choose_upload_log_host(
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_api() or has_web_server_logging():
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
@@ -483,6 +493,21 @@ def has_web_server_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_logging() -> bool:
|
||||
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
|
||||
|
||||
The ``web_server`` component exposes a ``/events`` Server-Sent Events
|
||||
stream that carries ``event: log`` frames. This requires version 2+ (the
|
||||
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
|
||||
"""
|
||||
web_conf = CORE.config.get(CONF_WEB_SERVER)
|
||||
if web_conf is None:
|
||||
return False
|
||||
if web_conf.get(CONF_VERSION, 2) == 1:
|
||||
return False
|
||||
return web_conf.get(CONF_LOG, True)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
if CONF_MQTT not in CORE.config:
|
||||
@@ -545,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
return mqtt.get_esphome_device_ip(
|
||||
config, username, password, client_id, stop_event=stop_event
|
||||
)
|
||||
|
||||
|
||||
def _add_network_device(device: str, network_devices: list[str]) -> None:
|
||||
"""Append a device to the list, expanding it through ``CORE.address_cache``.
|
||||
|
||||
If the hostname is already in the address cache (e.g. populated by mDNS
|
||||
discovery), substitute the cached IPs so aioesphomeapi doesn't open its
|
||||
own Zeroconf to re-resolve it. Duplicates are dropped.
|
||||
"""
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(addr for addr in cached if addr not in network_devices)
|
||||
elif device not in network_devices:
|
||||
network_devices.append(device)
|
||||
|
||||
|
||||
def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
|
||||
"""Split the device list into direct addresses and an MQTT-lookup flag.
|
||||
|
||||
Direct addresses are expanded through ``CORE.address_cache`` and deduped
|
||||
the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
|
||||
are not resolved, only reported via the returned bool so the caller can
|
||||
defer the broker lookup.
|
||||
"""
|
||||
network_devices: list[str] = []
|
||||
has_mqtt_lookup = False
|
||||
for device in devices:
|
||||
if get_port_type(device) in _MQTT_PORT_TYPES:
|
||||
has_mqtt_lookup = True
|
||||
else:
|
||||
_add_network_device(device, network_devices)
|
||||
return network_devices, has_mqtt_lookup
|
||||
|
||||
|
||||
def _resolve_network_devices(
|
||||
@@ -582,40 +644,44 @@ def _resolve_network_devices(
|
||||
if port_type in _MQTT_PORT_TYPES:
|
||||
# Only resolve MQTT once, even if multiple MQTT entries
|
||||
if not mqtt_resolved:
|
||||
try:
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
# pylint can't infer mqtt_get_ip's return through its
|
||||
# lazy ``from esphome import mqtt`` import, so it flags
|
||||
# the genexpr below.
|
||||
network_devices.extend(
|
||||
addr
|
||||
for addr in mqtt_ips # pylint: disable=not-an-iterable
|
||||
if addr not in network_devices
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
mqtt_ips = _mqtt_get_ip_or_warn(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(
|
||||
addr for addr in mqtt_ips if addr not in network_devices
|
||||
)
|
||||
mqtt_resolved = True
|
||||
continue
|
||||
|
||||
# If the hostname is already in the address cache (e.g. populated by
|
||||
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
|
||||
# open its own Zeroconf to re-resolve it.
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(
|
||||
addr for addr in cached if addr not in network_devices
|
||||
)
|
||||
elif device not in network_devices:
|
||||
# Regular network address or IP - add if not already present
|
||||
network_devices.append(device)
|
||||
_add_network_device(device, network_devices)
|
||||
|
||||
return network_devices
|
||||
|
||||
|
||||
def _mqtt_get_ip_or_warn(
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
) -> list[str]:
|
||||
"""Look up the device IP via MQTT, returning [] with a warning on failure.
|
||||
|
||||
This owns the failure policy for MQTT IP discovery on paths that have
|
||||
other addresses to fall back on: a broker problem must not abort the
|
||||
operation. Also used as the deferred resolver handed to ``run_logs``,
|
||||
where it runs in a worker thread.
|
||||
"""
|
||||
try:
|
||||
return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1291,25 +1357,23 @@ def _upload_via_native_api(
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
|
||||
from esphome import web_server_logs
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
port, username, password = get_web_server_connection(config)
|
||||
return web_server_logs.run_logs(network_devices, port, username, password)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
@@ -1418,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
return run_miniterm(config, port, args)
|
||||
|
||||
# Check if we should use API for logging
|
||||
# Resolve MQTT magic strings to actual IP addresses
|
||||
if has_api() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
from esphome.api_client import run_logs
|
||||
if has_api():
|
||||
network_devices, has_mqtt_lookup = _split_network_devices(devices)
|
||||
mqtt_resolver = None
|
||||
if has_mqtt_lookup:
|
||||
if network_devices:
|
||||
# Addresses are already known, so don't block startup on the
|
||||
# MQTT broker lookup; hand it to run_logs as a deferred
|
||||
# resolver that runs in the background and feeds discovered
|
||||
# addresses into the running log client, keeping MQTT as a
|
||||
# fallback for when the known addresses are stale (e.g. DHCP
|
||||
# reassigned the IP).
|
||||
mqtt_resolver = functools.partial(
|
||||
_mqtt_get_ip_or_warn,
|
||||
config,
|
||||
args.username,
|
||||
args.password,
|
||||
args.client_id,
|
||||
)
|
||||
else:
|
||||
# The MQTT lookup is the only way to find the device; resolve
|
||||
# it up front since the client needs an address to start with.
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
if network_devices:
|
||||
from esphome.api_client import run_logs
|
||||
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
)
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
from esphome import mqtt
|
||||
@@ -1437,6 +1521,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
config, args.topic, args.username, args.password, args.client_id
|
||||
)
|
||||
|
||||
# Fall back to the web_server HTTP SSE log stream for devices that have
|
||||
# web_server: but no api: (the logging counterpart to web_server OTA).
|
||||
if has_web_server_logging() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
return _show_logs_via_web_server(config, network_devices)
|
||||
|
||||
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
|
||||
|
||||
|
||||
@@ -1941,7 +2032,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:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
@@ -1954,7 +2045,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]:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
@@ -2001,7 +2092,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
> 1
|
||||
):
|
||||
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
@@ -2019,7 +2112,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:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2029,7 +2122,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():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2037,7 +2130,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
@@ -2045,7 +2138,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
@@ -2054,7 +2147,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
@@ -2080,7 +2173,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
@@ -2507,6 +2600,49 @@ def parse_args(argv):
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
"""Warn when the checkout the user is standing in is not the one being run.
|
||||
|
||||
An editable install records one absolute path, so a venv shared between git
|
||||
worktrees (or reused after a checkout is copied or renamed) keeps importing
|
||||
the tree it was installed from. Every command then silently runs, and
|
||||
compiles, sources the user is not looking at. Only fires inside a checkout,
|
||||
so ordinary installs never see it.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
return # working directory is gone; a diagnostic must not break startup
|
||||
for candidate in (cwd, *cwd.parents):
|
||||
if (candidate / "esphome" / "__main__.py").is_file():
|
||||
standing_in = candidate.resolve()
|
||||
break
|
||||
else:
|
||||
return # not inside a checkout; nothing to compare against
|
||||
|
||||
running = Path(__file__).resolve().parent.parent
|
||||
# Both sides are resolved, so on a case-sensitive filesystem this matches
|
||||
# plain equality. samefile() compares device and inode, which additionally
|
||||
# covers a case-insensitive filesystem (macOS) reaching one directory by
|
||||
# differently cased paths. Falls back to equality if either path is gone.
|
||||
try:
|
||||
same = standing_in.samefile(running)
|
||||
except OSError:
|
||||
same = standing_in == running
|
||||
if same:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Running ESPHome from a different checkout than the one you are in:\n"
|
||||
" running from: %s\n"
|
||||
" you are in: %s\n"
|
||||
"The installed esphome resolves to the first, so its sources are used.\n"
|
||||
"Run 'python -m esphome' from the second to use that one instead.",
|
||||
running,
|
||||
standing_in,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2525,6 +2661,7 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2595,7 +2732,8 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
@@ -2604,26 +2742,25 @@ def run_esphome(argv):
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today.
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
+84
-3
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
@@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.util import safe_print
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
@@ -32,8 +35,18 @@ async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop."""
|
||||
"""Run the logs command in the event loop.
|
||||
|
||||
If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
|
||||
has no asyncio support on Windows) concurrently with the connection
|
||||
attempts to ``addresses``, and any addresses it discovers are fed into
|
||||
the running client. It owns its own failure handling (returning [] when
|
||||
discovery fails) and must honor the ``threading.Event`` it is passed so
|
||||
teardown is not delayed by the lookup's wait window; the initial broker
|
||||
connect itself is only bounded by the socket timeout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
conf = config["api"]
|
||||
@@ -60,6 +73,41 @@ async def async_run_logs(
|
||||
# Decoder resolution policy lives in LogLineProcessor.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
|
||||
mqtt_task: asyncio.Task[None] | None = None
|
||||
mqtt_stop_event = threading.Event()
|
||||
|
||||
def _cancel_mqtt_discovery() -> None:
|
||||
"""Stop the broker lookup once a connection has been established.
|
||||
|
||||
Its answer is only useful while still disconnected: after that it
|
||||
either duplicates the connected address or arrives too late to
|
||||
matter, so don't keep an idle broker session open for it.
|
||||
"""
|
||||
mqtt_stop_event.set()
|
||||
if mqtt_task is not None and not mqtt_task.done():
|
||||
mqtt_task.cancel()
|
||||
|
||||
async def _resolve_mqtt_addresses() -> None:
|
||||
"""Discover the device address via the MQTT broker in the background."""
|
||||
try:
|
||||
mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
|
||||
if not mqtt_ips:
|
||||
_LOGGER.debug(
|
||||
"MQTT discovery %s",
|
||||
"aborted" if mqtt_stop_event.is_set() else "found no addresses",
|
||||
)
|
||||
return
|
||||
if cli.add_addresses(mqtt_ips):
|
||||
_LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# A background task failure would otherwise stay invisible for
|
||||
# the whole session and only re-raise at teardown
|
||||
_LOGGER.exception("MQTT address discovery failed")
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
@@ -98,20 +146,53 @@ async def async_run_logs(
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
|
||||
)
|
||||
try:
|
||||
# Don't start (or keep) the broker lookup if a connection already
|
||||
# succeeded; the stop event doubles as the not-needed-anymore latch
|
||||
# and get_esphome_device_ip returns immediately when it is set.
|
||||
if mqtt_resolver is not None and not mqtt_stop_event.is_set():
|
||||
mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await stop()
|
||||
try:
|
||||
if mqtt_task is not None:
|
||||
# Unblock the worker thread first so it can't hold up
|
||||
# loop.shutdown_default_executor() for the full lookup timeout.
|
||||
mqtt_stop_event.set()
|
||||
# Give the worker a moment to exit through its own error
|
||||
# handling; cancelling first would race out a late failure.
|
||||
done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
|
||||
if not done:
|
||||
mqtt_task.cancel()
|
||||
# return_exceptions keeps a CancelledError from the cancel()
|
||||
# above from re-raising here and jumping over the stop() below.
|
||||
# The task handles Exception itself, so only a BaseException
|
||||
# escape (e.g. SystemExit from the worker) can land here.
|
||||
(result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
|
||||
if isinstance(result, BaseException) and not isinstance(
|
||||
result, asyncio.CancelledError
|
||||
):
|
||||
_LOGGER.error("MQTT address discovery failed", exc_info=result)
|
||||
finally:
|
||||
# Must run even if a second cancellation lands mid-cleanup above
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(config, addresses, subscribe_states=subscribe_states)
|
||||
async_run_logs(
|
||||
config,
|
||||
addresses,
|
||||
subscribe_states=subscribe_states,
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
)
|
||||
|
||||
+171
-50
@@ -1,26 +1,178 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, 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
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
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 or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
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"
|
||||
|
||||
|
||||
@@ -32,52 +184,21 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
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).
|
||||
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
if isinstance(value, Lambda):
|
||||
return {_LAMBDA_KEY: value.value}
|
||||
return str(value)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Component alias registry.
|
||||
|
||||
Generated by script/build_alias_registry.py - do not edit manually.
|
||||
See the component-alias section of esphome/loader.py.
|
||||
"""
|
||||
|
||||
# alias -> (canonical component, removal version or None)
|
||||
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
|
||||
"rp2040": ("rp2", "2027.7.0"),
|
||||
}
|
||||
@@ -231,6 +231,7 @@ def validate_adc_pin(value):
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
if (!initialized) {
|
||||
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
|
||||
if (this->is_temperature_) {
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
delay(1);
|
||||
adc_select_input(4);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
|
||||
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
|
||||
raw = adc_read();
|
||||
|
||||
@@ -67,6 +67,13 @@ def validate_config(config):
|
||||
# Alter value here so `config` command prints the recommended change
|
||||
config[CONF_ATTENUATION] = _attenuation("12db")
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if config[CONF_PIN] == "TEMPERATURE":
|
||||
_LOGGER.warning(
|
||||
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
|
||||
"sensor platform instead. Will be removed in 2027.2.0"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -133,6 +140,7 @@ async def to_code(config):
|
||||
if config[CONF_PIN] == "VCC":
|
||||
cg.add_define("USE_ADC_SENSOR_VCC")
|
||||
elif config[CONF_PIN] == "TEMPERATURE":
|
||||
# Remove before 2027.2.0
|
||||
cg.add(var.set_is_temperature())
|
||||
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from . import image as animation_image
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
# The deprecated top-level `animation:` shim gets the same batched
|
||||
# downloads as the `image:` platform form.
|
||||
PREFETCH_FILES = animation_image.PREFETCH_FILES
|
||||
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file import image as file_image
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
@@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
|
||||
# The animation platform shares the file platform's remote file handling,
|
||||
# including its batch-download hook.
|
||||
PREFETCH_FILES = file_image.PREFETCH_FILES
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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) {}
|
||||
@@ -243,6 +244,12 @@ 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;
|
||||
@@ -280,6 +287,8 @@ 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];
|
||||
@@ -288,11 +297,14 @@ 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
|
||||
@@ -305,10 +317,13 @@ 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
|
||||
@@ -317,6 +332,63 @@ 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;
|
||||
@@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType {
|
||||
message BluetoothDeviceRequest {
|
||||
option (id) = 68;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
BluetoothDeviceRequestType request_type = 2;
|
||||
@@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest {
|
||||
message BluetoothDeviceConnectionResponse {
|
||||
option (id) = 69;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool connected = 2;
|
||||
@@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
|
||||
message BluetoothGATTGetServicesRequest {
|
||||
option (id) = 70;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1754,7 +1826,7 @@ message BluetoothGATTService {
|
||||
message BluetoothGATTGetServicesResponse {
|
||||
option (id) = 71;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
repeated BluetoothGATTService services = 2;
|
||||
@@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
|
||||
message BluetoothGATTGetServicesDoneResponse {
|
||||
option (id) = 72;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
|
||||
message BluetoothGATTReadRequest {
|
||||
option (id) = 73;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest {
|
||||
message BluetoothGATTReadResponse {
|
||||
option (id) = 74;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse {
|
||||
message BluetoothGATTWriteRequest {
|
||||
option (id) = 75;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest {
|
||||
message BluetoothGATTReadDescriptorRequest {
|
||||
option (id) = 76;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
|
||||
message BluetoothGATTWriteDescriptorRequest {
|
||||
option (id) = 77;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
|
||||
message BluetoothGATTNotifyRequest {
|
||||
option (id) = 78;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
|
||||
message BluetoothGATTNotifyDataResponse {
|
||||
option (id) = 79;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
|
||||
message SubscribeBluetoothConnectionsFreeRequest {
|
||||
option (id) = 80;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
}
|
||||
|
||||
message BluetoothConnectionsFreeResponse {
|
||||
option (id) = 81;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint32 free = 1;
|
||||
uint32 limit = 2;
|
||||
@@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
|
||||
message BluetoothGATTErrorResponse {
|
||||
option (id) = 82;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse {
|
||||
message BluetoothGATTWriteResponse {
|
||||
option (id) = 83;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse {
|
||||
message BluetoothGATTNotifyResponse {
|
||||
option (id) = 84;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
|
||||
message BluetoothDevicePairingResponse {
|
||||
option (id) = 85;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool paired = 2;
|
||||
@@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse {
|
||||
message BluetoothDeviceUnpairingResponse {
|
||||
option (id) = 86;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
|
||||
message BluetoothDeviceClearCacheResponse {
|
||||
option (id) = 88;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse {
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 min_interval = 2; // units of 1.25ms
|
||||
@@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
|
||||
message BluetoothSetConnectionParamsResponse {
|
||||
option (id) = 146;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#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
|
||||
@@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
|
||||
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
|
||||
|
||||
static const char *const TAG = "api.connection";
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what) {
|
||||
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
|
||||
LOG_STR_ARG(what));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
static const int CAMERA_STOP_STREAM = 5000;
|
||||
#endif
|
||||
@@ -152,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
|
||||
#else
|
||||
#error "No frame helper defined"
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
if (camera::Camera::instance() != nullptr) {
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIConnection::start() {
|
||||
@@ -440,7 +443,7 @@ void APIConnection::on_disconnect_response() {
|
||||
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
msg.key = entity->get_entity_key();
|
||||
msg.key = entity->get_object_id_hash();
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
@@ -451,7 +454,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
// Set common fields that are shared by all entity types
|
||||
msg.key = entity->get_entity_key();
|
||||
msg.key = entity->get_object_id_hash();
|
||||
|
||||
if (entity->has_own_name()) {
|
||||
msg.name = entity->get_name();
|
||||
@@ -1132,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
|
||||
if (!this->image_reader_)
|
||||
return;
|
||||
|
||||
const auto *cam = camera::Camera::instance();
|
||||
// Send as many chunks as possible without blocking
|
||||
while (this->image_reader_->available()) {
|
||||
if (!this->helper_->can_write_without_blocking())
|
||||
@@ -1141,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = camera::Camera::instance()->get_entity_key();
|
||||
msg.key = cam->get_object_id_hash();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = camera::Camera::instance()->get_device_id();
|
||||
msg.device_id = cam->get_device_id();
|
||||
#endif
|
||||
|
||||
if (!this->send_message(msg)) {
|
||||
@@ -1161,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
|
||||
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
||||
if (!this->flags_.state_subscription)
|
||||
return;
|
||||
if (!this->image_reader_)
|
||||
if (this->image_reader_ && this->image_reader_->available())
|
||||
return;
|
||||
if (this->image_reader_->available())
|
||||
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
|
||||
return;
|
||||
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
if (!this->image_reader_) {
|
||||
// Created on the first image this connection will send, so connections
|
||||
// that never receive one never pay for a reader. Only a registered
|
||||
// camera's listener can reach this, so instance() is non-null here.
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
}
|
||||
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *camera = static_cast<camera::Camera *>(entity);
|
||||
@@ -1235,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
|
||||
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
|
||||
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
|
||||
}
|
||||
@@ -1268,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
|
||||
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
|
||||
}
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -1532,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
// would flood the congested link it reports on.
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -1574,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
SerialProxyGetModemPinsResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
@@ -1606,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -1615,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1735,7 +1760,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 14;
|
||||
resp.api_version_minor = 15;
|
||||
// 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());
|
||||
@@ -1746,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Hello response");
|
||||
}
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
@@ -1771,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
|
||||
#ifdef USE_AREAS
|
||||
resp.suggested_area = StringRef(App.get_area());
|
||||
#endif
|
||||
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char mac_address[18];
|
||||
uint8_t mac[6];
|
||||
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, mac_address);
|
||||
resp.mac_address = StringRef(mac_address);
|
||||
@@ -1849,8 +1875,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char bluetooth_mac[18];
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
@@ -1904,6 +1929,35 @@ 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();
|
||||
@@ -1925,6 +1979,11 @@ 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) {
|
||||
@@ -2003,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.call_id = call_id;
|
||||
resp.success = success;
|
||||
resp.error_message = error_message;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
|
||||
@@ -2014,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.error_message = error_message;
|
||||
resp.response_data = response_data;
|
||||
resp.response_data_len = response_data_len;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
if (!this->send_message(call)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action request");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif // USE_API_HOMEASSISTANT_SERVICES
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void APIConnection::send_time_request() {
|
||||
GetTimeRequest req;
|
||||
if (!this->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Time request");
|
||||
}
|
||||
}
|
||||
#endif // USE_HOMEASSISTANT_TIME
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -2094,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
if (this->helper_->can_write_without_blocking())
|
||||
return true;
|
||||
if (log_out_of_space) {
|
||||
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
|
||||
// VV: refusals are either reported by the sending call site (naming what
|
||||
// was lost) or retried without loss (the deferred batch), so this generic
|
||||
// line only duplicates them.
|
||||
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
#endif
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <functional>
|
||||
@@ -40,6 +41,16 @@ namespace esphome::api {
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// One shared flash string for every refused-frame warning: send_message()
|
||||
// fails as soon as the TCP buffer is full, and each caller only pays for its
|
||||
// short name. The guard drops the helper and its arguments below WARN.
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what);
|
||||
#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
|
||||
#else
|
||||
#define API_LOG_MSG_DROPPED(tag, what)
|
||||
#endif
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call);
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
|
||||
void on_unsubscribe_bluetooth_le_advertisements_request();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
|
||||
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
|
||||
void on_subscribe_bluetooth_connections_free_request();
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
|
||||
#endif
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void send_time_request() {
|
||||
GetTimeRequest req;
|
||||
this->send_message(req);
|
||||
}
|
||||
void send_time_request();
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -266,6 +271,7 @@ 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;
|
||||
@@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
template<typename T> bool send_message(const T &msg) {
|
||||
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
|
||||
/// have no silent failures: every caller must handle (or log) a refusal.
|
||||
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
|
||||
} else {
|
||||
@@ -385,10 +393,11 @@ 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
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool send_subscribe_bluetooth_connections_free_response_();
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
|
||||
@@ -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 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×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 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
|
||||
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
*/
|
||||
APIError APINoiseFrameHelper::init_handshake_() {
|
||||
int err;
|
||||
memset(&nid_, 0, sizeof(nid_));
|
||||
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
|
||||
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
|
||||
nid_.pattern_id = NOISE_PATTERN_NN;
|
||||
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
|
||||
nid_.dh_id = NOISE_DH_CURVE25519;
|
||||
nid_.prefix_id = NOISE_PREFIX_STANDARD;
|
||||
nid_.hybrid_id = NOISE_DH_NONE;
|
||||
nid_.hash_id = NOISE_HASH_SHA256;
|
||||
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
|
||||
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
|
||||
// noise_handshakestate_new_by_id copies it, so a member would waste
|
||||
// 104 bytes per connection, and a static const would sit in RAM on
|
||||
// ESP8266 (.rodata is DRAM there).
|
||||
const NoiseProtocolId nid = {
|
||||
.prefix_id = NOISE_PREFIX_STANDARD,
|
||||
.pattern_id = NOISE_PATTERN_NN,
|
||||
.modifier_ids = {NOISE_MODIFIER_PSK0},
|
||||
.dh_id = NOISE_DH_CURVE25519,
|
||||
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
};
|
||||
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
|
||||
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
|
||||
APIError aerr =
|
||||
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
|
||||
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Buffer for noise handshake prologue (released after handshake)
|
||||
APIBuffer prologue_;
|
||||
|
||||
// NoiseProtocolId (size depends on implementation)
|
||||
NoiseProtocolId nid_;
|
||||
|
||||
// Group small types together
|
||||
// Fixed-size header buffer for noise protocol:
|
||||
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
|
||||
|
||||
@@ -241,6 +241,82 @@ 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();
|
||||
@@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
|
||||
}
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
@@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_int32(1, this->error);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
|
||||
@@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
|
||||
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
|
||||
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
|
||||
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
enum BluetoothScannerState : uint32_t {
|
||||
BLUETOOTH_SCANNER_STATE_IDLE = 0,
|
||||
BLUETOOTH_SCANNER_STATE_STARTING = 1,
|
||||
@@ -600,6 +602,74 @@ 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;
|
||||
@@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 68;
|
||||
@@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothScannerStateResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 126;
|
||||
@@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 145;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
|
||||
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
template<>
|
||||
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
|
||||
switch (value) {
|
||||
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
|
||||
switch (value) {
|
||||
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
|
||||
@@ -988,6 +990,55 @@ 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();
|
||||
@@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
|
||||
}
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
@@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error"), this->error);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
|
||||
@@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
|
||||
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothDeviceRequest::MESSAGE_TYPE: {
|
||||
BluetoothDeviceRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTGetServicesRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTNotifyRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
|
||||
@@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
|
||||
BluetoothSetConnectionParamsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -705,6 +705,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ 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(){};
|
||||
@@ -113,32 +115,32 @@ class APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_subscribe_bluetooth_connections_free_request(){};
|
||||
#endif
|
||||
|
||||
@@ -233,7 +235,7 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -123,7 +123,9 @@ void APIServer::setup() {
|
||||
// Best-effort: if the send buffer is full the reason is dropped, but the
|
||||
// client still learns the window is closed when it reconnects (rejected at
|
||||
// hello) or via the socket close.
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) {
|
||||
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
// We could add code to manage a second subscription type, but, since this message type is
|
||||
// very infrequent and small, we simply send it to all clients
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_message(msg);
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (!c->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ async def to_code(config):
|
||||
data.wav_support = True
|
||||
|
||||
if data.micro_decoder_support:
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.4.0")
|
||||
|
||||
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
|
||||
if not data.flac_support:
|
||||
@@ -380,6 +380,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
|
||||
if not data.opus_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
|
||||
# Vorbis is unsupported in ESPHome, so always disable it
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False)
|
||||
if not data.wav_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
|
||||
on this component and contain no SDK calls of their own.
|
||||
|
||||
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
|
||||
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
|
||||
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
|
||||
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
|
||||
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
|
||||
with a clear #error.
|
||||
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
|
||||
to_code; unknown families are capability-checked at compile time via
|
||||
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
|
||||
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
|
||||
fails with a clear #error.
|
||||
|
||||
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
|
||||
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
|
||||
@@ -21,9 +21,16 @@ import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_BK7251,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
@@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
|
||||
def _unsupported_family_message(family: str) -> str | None:
|
||||
if family in (FAMILY_BK7231T, FAMILY_BK7251):
|
||||
return (
|
||||
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
|
||||
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
|
||||
)
|
||||
if family == FAMILY_BK7231Q:
|
||||
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
|
||||
return None
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# Warn only: a hard error here would break the validate-only CI fixtures,
|
||||
# which run on a BLE 4.2 board. The hard error is raised at codegen.
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
_LOGGER.warning("%s (this configuration cannot compile)", msg)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
raise EsphomeError(msg)
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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") && __has_include("app_ble.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 && app_ble.h
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,51 @@
|
||||
#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
|
||||
@@ -5,7 +5,8 @@
|
||||
// talks to the Beken BDK BLE stack:
|
||||
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
|
||||
// - the controller BLE address,
|
||||
// - the raw controller scan primitives (bk_ble_scan_start/stop),
|
||||
// - the scan reconciler (request, pacing, bring-up budget) over the
|
||||
// bdk_scan surface,
|
||||
// - 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
|
||||
@@ -20,32 +21,39 @@
|
||||
|
||||
#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"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-capability gate (not a chip allowlist).
|
||||
// This component drives the Beken BLE *5.x* controller via its public API,
|
||||
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
|
||||
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
|
||||
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
|
||||
// itself so any BLE-5.x Beken chip — present or future — is supported without a
|
||||
// hard-coded list, and a non-5.x build fails here with a clear message instead
|
||||
// of a cryptic "ble_api.h: No such file or directory".
|
||||
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
|
||||
// the probe: it ships for every SoC (driver/include) and merely switches on
|
||||
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
|
||||
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
|
||||
// any BLE-5.x chip — present or future — without a hard-coded list, and a
|
||||
// non-5.x build fails here with a clear message instead of a cryptic
|
||||
// "app_ble.h: No such file or directory".
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(CLANG_TIDY)
|
||||
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
|
||||
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
|
||||
// accurate to analyze the SDK calls against — skip the file under analysis.
|
||||
#define BK72XX_BLE_NO_SDK
|
||||
#elif !__has_include("ble_api.h")
|
||||
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
|
||||
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
|
||||
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
|
||||
// one and bury this message.
|
||||
#define BK72XX_BLE_NO_SDK
|
||||
#error \
|
||||
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
|
||||
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
|
||||
#endif
|
||||
|
||||
#ifndef BK72XX_BLE_NO_SDK
|
||||
@@ -57,9 +65,8 @@
|
||||
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" {
|
||||
#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
|
||||
#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)
|
||||
#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().
|
||||
@@ -76,6 +83,12 @@ 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)
|
||||
@@ -95,21 +108,22 @@ 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, info->data,
|
||||
info->data_len);
|
||||
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);
|
||||
}
|
||||
|
||||
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_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, uint8_t evt_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, 6);
|
||||
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
|
||||
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);
|
||||
@@ -123,6 +137,9 @@ 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_();
|
||||
@@ -173,6 +190,30 @@ 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();
|
||||
@@ -193,7 +234,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[6]) const {
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = this->ble_mac_[i];
|
||||
}
|
||||
@@ -226,7 +267,7 @@ void BK72xxBLE::resolve_mac_() {
|
||||
}
|
||||
}
|
||||
if (nonzero) {
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -238,54 +279,238 @@ 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[6];
|
||||
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(wifi_mac); // MSB-first
|
||||
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)};
|
||||
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)};
|
||||
// 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];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller scan primitives
|
||||
// Scan reconciler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
|
||||
// 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) {
|
||||
if (!this->is_active())
|
||||
this->enable();
|
||||
|
||||
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();
|
||||
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_();
|
||||
}
|
||||
this->scan_wanted_ = true;
|
||||
this->requested_ = params;
|
||||
return 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;
|
||||
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_();
|
||||
}
|
||||
|
||||
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");
|
||||
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
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
|
||||
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;
|
||||
}
|
||||
|
||||
void BK72xxBLE::scan_stop() {
|
||||
if (this->scan_actv_idx_ != 0xFF) {
|
||||
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
|
||||
this->scan_actv_idx_ = 0xFF;
|
||||
// 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;
|
||||
}
|
||||
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,6 +11,8 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "bdk_scan.h"
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
enum class BLEComponentState : uint8_t {
|
||||
@@ -19,11 +21,32 @@ 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[6]; // LSB-first, as the controller delivers it
|
||||
int8_t rssi; // signed dBm
|
||||
uint8_t mac[MAC_ADDRESS_SIZE]; // 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)
|
||||
|
||||
@@ -60,7 +83,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[6]) const;
|
||||
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
@@ -69,18 +92,33 @@ class BK72xxBLE final : public Component {
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
#endif
|
||||
|
||||
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
|
||||
/// Enables the stack first if needed. Returns false on controller failure.
|
||||
bool scan_start(uint16_t interval, uint16_t window);
|
||||
/// Stop the controller scan (no-op when not scanning).
|
||||
/// 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().
|
||||
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, const uint8_t *data, uint16_t data_len);
|
||||
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);
|
||||
|
||||
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 —
|
||||
@@ -95,10 +133,24 @@ 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_;
|
||||
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
|
||||
uint8_t scan_actv_idx_{0xFF};
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
// 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};
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -146,6 +147,9 @@ 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)
|
||||
@@ -164,6 +168,7 @@ 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,10 +9,9 @@
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
@@ -27,6 +26,15 @@ 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
|
||||
@@ -36,11 +44,20 @@ 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
|
||||
@@ -50,30 +67,54 @@ 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 = millis();
|
||||
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);
|
||||
}
|
||||
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
// 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.
|
||||
// One-iteration deferral; all stamps share this iteration's cached
|
||||
// timestamp, so the period check below cannot underflow.
|
||||
if (this->try_start_with_backoff_(now))
|
||||
return;
|
||||
}
|
||||
@@ -81,11 +122,7 @@ 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_) {
|
||||
#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->fire_scan_end_();
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
return;
|
||||
@@ -99,13 +136,14 @@ 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 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.
|
||||
// Same one-iteration deferral as the continuous branch.
|
||||
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_();
|
||||
}
|
||||
}
|
||||
@@ -122,32 +160,54 @@ 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.
|
||||
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))
|
||||
// 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)
|
||||
return false;
|
||||
this->last_scan_start_attempt_ = now;
|
||||
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->start_scan_();
|
||||
if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
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) {
|
||||
++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: %.0f ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Type: PASSIVE\n"
|
||||
" Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Type: %s (configured %s)\n"
|
||||
" Continuous Scanning: %s",
|
||||
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
|
||||
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
|
||||
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_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -156,31 +216,33 @@ void BK72xxBLETracker::dump_config() {
|
||||
// listener dispatch run in main-loop context with no cross-task handling here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
|
||||
// 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);
|
||||
}
|
||||
// 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;
|
||||
|
||||
#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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,7 +269,8 @@ void BK72xxBLETracker::start_scan() {
|
||||
// against a failing controller, repeated start_scan() calls are rate-limited
|
||||
// like any other attempt.
|
||||
this->scan_requested_ = true;
|
||||
this->try_start_with_backoff_(millis(), /* force= */ 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);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::restart_scan_duration() {
|
||||
@@ -218,7 +281,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_ = millis();
|
||||
this->scan_start_time_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan() {
|
||||
@@ -231,24 +294,31 @@ 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->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
|
||||
static_cast<uint16_t>(this->scan_window_)))
|
||||
if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
|
||||
return;
|
||||
|
||||
const uint32_t now = millis();
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->scan_running_ = true;
|
||||
this->scan_requested_ = false; // the latched one-shot request is satisfied
|
||||
this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too
|
||||
this->start_attempt_open_ = false;
|
||||
// failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
|
||||
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 (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
|
||||
this->scan_interval_ * 0.625f);
|
||||
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_));
|
||||
// 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
|
||||
@@ -258,18 +328,49 @@ 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_() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
this->parent_->scan_stop();
|
||||
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) {
|
||||
this->scan_running_ = false;
|
||||
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
|
||||
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;
|
||||
// V: the proxy's "Setting scanner mode" line already narrates this at D.
|
||||
ESP_LOGV(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;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// window: 30ms
|
||||
// duration: 5min
|
||||
// continuous: true
|
||||
// active: true
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -29,6 +30,7 @@
|
||||
#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"
|
||||
|
||||
@@ -69,6 +71,12 @@ 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) {
|
||||
@@ -93,37 +101,29 @@ class BK72xxBLETracker : public Component,
|
||||
|
||||
// ---- ble_device_base::BLEHub contract ----
|
||||
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
this->listeners_.push_back(listener);
|
||||
#endif
|
||||
this->dispatcher_.register_listener(listener);
|
||||
}
|
||||
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
|
||||
this->raw_advertisement_callback_ = callback;
|
||||
this->dispatcher_.set_raw_advertisement_callback(callback);
|
||||
}
|
||||
static constexpr ble_device_base::HubCapabilities get_capabilities() {
|
||||
// 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;
|
||||
// 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};
|
||||
}
|
||||
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[6]) {
|
||||
uint8_t mac[6];
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
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 false; } // BK72xx scan is passive-only
|
||||
bool scan_active() { return this->scan_active_; }
|
||||
|
||||
// ---- bk72xx_ble::BLEScanListener ----
|
||||
// Delivered by the controller's loop() on the ESPHome main task — the
|
||||
@@ -133,15 +133,20 @@ class BK72xxBLETracker : public Component,
|
||||
protected:
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
/// 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.
|
||||
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.
|
||||
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 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
|
||||
// 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
|
||||
@@ -149,30 +154,27 @@ 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}; // 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()
|
||||
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()
|
||||
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
|
||||
|
||||
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
|
||||
// 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_;
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
@@ -37,7 +37,7 @@ from esphome.const import (
|
||||
CONF_INTERVAL,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE, ID, KEY_CORE
|
||||
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
@@ -163,8 +163,9 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
|
||||
|
||||
def request_gatt_client() -> None:
|
||||
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
|
||||
claim one connection slot. Called by bluetooth_proxy once per connection
|
||||
it instantiates on a hub platform."""
|
||||
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."""
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
_request_gatt_connection_slot()
|
||||
|
||||
@@ -242,28 +243,35 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# The historical scan window default shared by the trackers that do not pin
|
||||
# their own; also the fallback for esp32's conditional default.
|
||||
DEFAULT_SCAN_WINDOW = "30ms"
|
||||
|
||||
|
||||
def scan_parameters_schema(
|
||||
interval_default: str,
|
||||
*,
|
||||
window_default: str = "30ms",
|
||||
supports_active: bool = False,
|
||||
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
|
||||
) -> 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). 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).
|
||||
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
|
||||
callable evaluated per validation when the user omits the key (esp32 uses
|
||||
this to record that the window was defaulted, so a later validation step
|
||||
can adjust it once sibling keys are resolved). 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.
|
||||
"""
|
||||
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,11 +17,17 @@ 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[6];
|
||||
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
|
||||
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_[6]{0};
|
||||
uint8_t address_[MAC_ADDRESS_SIZE]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
// Fixed buffer instead of std::string: no per-advertisement heap churn on
|
||||
|
||||
@@ -5,14 +5,11 @@
|
||||
// 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 - 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.
|
||||
// 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.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// 0 success
|
||||
@@ -82,17 +79,12 @@ struct GattServiceTable {
|
||||
uint16_t descriptor_count{0};
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
class GattClientListener {
|
||||
public:
|
||||
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
|
||||
@@ -110,7 +102,13 @@ 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).
|
||||
// - disconnect: also cancels a connect in progress.
|
||||
// - 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).
|
||||
// - 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,
|
||||
@@ -119,16 +117,17 @@ 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 disconnect land in on_connection_state,
|
||||
// - completions: connect and gatt_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 response and descriptor
|
||||
// writes in on_write_result.
|
||||
// on_notify_state, characteristic writes (with and without 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.disconnect() } -> std::same_as<int>;
|
||||
{ conn.gatt_disconnect() } -> std::same_as<int>;
|
||||
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
|
||||
{ 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>;
|
||||
@@ -144,51 +143,6 @@ 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
|
||||
|
||||
@@ -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 (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).
|
||||
/// 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).
|
||||
bool scan_mode_switch;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#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
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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,35 +1,29 @@
|
||||
"""Per-platform GATT connection backends and the helpers to embed one.
|
||||
|
||||
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().
|
||||
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().
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_MAC_ADDRESS,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_RP2,
|
||||
PlatformFramework,
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import 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, so consumers stay platform-blind. The platform-less arm
|
||||
serves tooling that resolves the manifest without a target."""
|
||||
registers with (the Bluedroid header includes the tracker's), so
|
||||
consumers need not know. The platform-less arm serves manifest tooling."""
|
||||
if CORE.is_esp32:
|
||||
return ["ble_device_base", "esp32_ble_tracker"]
|
||||
if CORE.is_rp2:
|
||||
@@ -43,12 +37,15 @@ 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;
|
||||
# raising this needs an upstream change (the layer itself supports N).
|
||||
RP2_MAX_CONNECTIONS = 1
|
||||
# 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
|
||||
|
||||
# Hub platforms with a GATT backend, mapped to their slot limit — the single
|
||||
# registry of which hub platforms run the connection-capable proxy.
|
||||
# Slot limits for the hub platforms running the connection-capable proxy;
|
||||
# the backend registry itself is _PLATFORM_BACKENDS below.
|
||||
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
|
||||
|
||||
# The hub-platform wrapper and the backend codegen classes.
|
||||
@@ -59,10 +56,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
|
||||
)
|
||||
|
||||
CONF_BACKEND_ID = "backend_id"
|
||||
CONF_ADDRESS_TYPE = "address_type"
|
||||
|
||||
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
|
||||
ADDRESS_TYPES = {"public": 0, "random": 1}
|
||||
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]
|
||||
|
||||
|
||||
def _esp32_schema_fragment() -> cv.Schema:
|
||||
@@ -72,8 +78,6 @@ 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)}
|
||||
)
|
||||
@@ -82,21 +86,35 @@ 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's
|
||||
# tracker-facing shim registers as a raw client.
|
||||
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
|
||||
# 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)
|
||||
|
||||
|
||||
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.components import ota
|
||||
|
||||
# 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 (lazy so the
|
||||
platform stack is only imported when targeted), and stack registration."""
|
||||
"""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)."""
|
||||
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
@@ -113,9 +131,6 @@ _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
|
||||
@@ -140,131 +155,49 @@ 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. Same platform rules as gatt_client_schema()."""
|
||||
return gatt_client_schema(platform).extend(
|
||||
{cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema() and register
|
||||
it with its platform stack. The connection slot is claimed at validation
|
||||
(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.
|
||||
(the proxy's slot validators), not here.
|
||||
"""
|
||||
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 has no user-facing component options; an empty config keeps
|
||||
# the consumer's own keys (update_interval, ...) off it.
|
||||
await cg.register_component(backend, {})
|
||||
# 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)
|
||||
await _backend_entry().register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
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},
|
||||
}
|
||||
)
|
||||
# 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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_bt_device.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -44,14 +44,13 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
|
||||
// including advertisement-only ones where no GATT backend (and none of the
|
||||
// gated surface above) is compiled - so this block sits outside that gate.
|
||||
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
|
||||
// the advertisement-only arm no longer dispatches these requests at all.
|
||||
|
||||
conn_err_t unpair_device(uint64_t address) {
|
||||
esp_bd_addr_t bda;
|
||||
@@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
|
||||
|
||||
@@ -16,17 +16,14 @@
|
||||
#include <esp_err.h>
|
||||
#endif
|
||||
|
||||
// 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_SERVES_PROXY
|
||||
#endif
|
||||
// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has
|
||||
// proxy connection slots": codegen emits it per configured slot, and each
|
||||
// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not
|
||||
// the converse: a backend can exist without proxy slots). The hub
|
||||
// wrapper, the proxy's connection surface and the API's connection messages
|
||||
// all gate on it. The address-scoped maintenance functions below are only
|
||||
// reached from that gated surface; the #else stubs just keep this header
|
||||
// parsing on arms without a backend.
|
||||
|
||||
namespace esphome::api {
|
||||
class BluetoothGATTGetServicesResponse;
|
||||
@@ -68,12 +65,12 @@ static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#endif
|
||||
|
||||
// Address-scoped (not connection-scoped) maintenance requests.
|
||||
#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT))
|
||||
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t unpair_device(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t clear_gatt_cache(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
@@ -82,6 +79,20 @@ 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;
|
||||
// Owed-ack retries stop after ~25 s of subscribed drain time from the first
|
||||
// refusal, keeping most of the client's 30 s GATT window for congestion to
|
||||
// clear while still bounding how stale a delivered reply can be.
|
||||
static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250;
|
||||
|
||||
// ---- Service-streaming size budget, shared by every platform's streamer ----
|
||||
|
||||
@@ -141,7 +152,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// 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.
|
||||
@@ -153,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
|
||||
/// cannot drift.
|
||||
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service,
|
||||
uint8_t connection_index, const char *address_str);
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
#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 BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#include "bluetooth_connection.h"
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -17,8 +16,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <esp_gatt_common_api.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
@@ -34,19 +31,10 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
|
||||
using esp32_ble_tracker::ClientState;
|
||||
using esp32_ble_tracker::ConnectionType;
|
||||
|
||||
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
|
||||
// ---- tracker surface ----
|
||||
|
||||
// ---- 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(); }
|
||||
void BluedroidGattClient::connect() { this->tracker_connect_(); }
|
||||
void BluedroidGattClient::disconnect() { this->gatt_disconnect(); }
|
||||
|
||||
// ---- component ----
|
||||
|
||||
@@ -57,30 +45,45 @@ void BluedroidGattClient::setup() {
|
||||
|
||||
void BluedroidGattClient::loop() {
|
||||
if (!esp32_ble::global_ble->is_active()) {
|
||||
// Stack down: re-register the app on the next enable.
|
||||
this->set_state_(ClientState::INIT);
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
auto st = this->state_();
|
||||
auto st = this->state();
|
||||
if (st == ClientState::INIT) {
|
||||
auto ret = esp_ble_gattc_app_register(this->shim_.app_id);
|
||||
// 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);
|
||||
if (ret) {
|
||||
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret);
|
||||
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->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_();
|
||||
this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +97,9 @@ void BluedroidGattClient::dump_config() {
|
||||
// ---- contract ops ----
|
||||
|
||||
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
// 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) {
|
||||
// Only from idle: clobbering DISCONNECTING would open a new link the
|
||||
// stale CLOSE_EVT then tears down.
|
||||
if (this->state() != ClientState::IDLE) {
|
||||
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
|
||||
return ESP_GATT_BUSY;
|
||||
}
|
||||
@@ -105,12 +107,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;
|
||||
@@ -120,30 +122,35 @@ 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.
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
|
||||
FAST_CONN_TIMEOUT);
|
||||
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));
|
||||
} else {
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0,
|
||||
MEDIUM_CONN_TIMEOUT);
|
||||
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));
|
||||
}
|
||||
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, so conn_id_ is legitimately unset: plain IDLE.
|
||||
this->set_state_(ClientState::IDLE);
|
||||
this->report_connection_state_(false, ret);
|
||||
// CONNECT_EVT never fired; nothing to close.
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ret);
|
||||
}
|
||||
}
|
||||
|
||||
int BluedroidGattClient::disconnect() {
|
||||
auto st = this->state_();
|
||||
int BluedroidGattClient::gatt_disconnect() {
|
||||
auto st = this->state();
|
||||
if (st == ClientState::DISCONNECTING) {
|
||||
return 0;
|
||||
}
|
||||
@@ -154,12 +161,15 @@ int BluedroidGattClient::disconnect() {
|
||||
}
|
||||
if (st == ClientState::DISCOVERED) {
|
||||
// Parked for the tracker promote loop, never opened.
|
||||
this->set_state_(ClientState::IDLE);
|
||||
this->set_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->shim_.schedule_disconnect();
|
||||
this->want_disconnect_ = true;
|
||||
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
|
||||
this->disconnecting_started_ = millis();
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
}
|
||||
this->unconditional_disconnect_();
|
||||
@@ -169,7 +179,11 @@ int BluedroidGattClient::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_);
|
||||
@@ -180,20 +194,58 @@ 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;
|
||||
}
|
||||
return this->check_and_log_error_("esp_ble_gattc_search_service",
|
||||
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -203,12 +255,18 @@ 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),
|
||||
@@ -216,6 +274,9 @@ 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",
|
||||
@@ -225,7 +286,12 @@ 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() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
|
||||
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::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
|
||||
uint16_t timeout) {
|
||||
@@ -234,190 +300,18 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
this->free_service_table_();
|
||||
#endif
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
// Only the cache clean makes the stack's database unsafe to walk.
|
||||
// Always set: terminates any in-flight stream on every cache config.
|
||||
this->services_released_ = true;
|
||||
esp_ble_gattc_cache_clean(this->remote_bda_);
|
||||
#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_));
|
||||
}
|
||||
#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 {
|
||||
@@ -425,21 +319,19 @@ 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}};
|
||||
@@ -465,9 +357,15 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
|
||||
|
||||
// ---- service streaming ----
|
||||
|
||||
void BluedroidGattClient::handle_search_cmpl_() {
|
||||
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
// 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,
|
||||
@@ -475,34 +373,52 @@ void BluedroidGattClient::handle_search_cmpl_() {
|
||||
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 - 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;
|
||||
// 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;
|
||||
}
|
||||
this->service_total_ = primary + secondary;
|
||||
this->listener_->on_service_discovery_done(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// 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_CONNECTIONS
|
||||
// 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); });
|
||||
|
||||
// Bound by the SERVICE STREAMING HAZARD note at the top of
|
||||
// bluetooth_connection_hub.cpp: never skip a batch, never send done early.
|
||||
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
|
||||
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_);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
conn.proxy_->send_gatt_services_done(conn.address_);
|
||||
return;
|
||||
}
|
||||
if (conn.send_service_ >= this->service_total_) {
|
||||
this->release_services();
|
||||
conn.send_services_done_();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// The subscriber vanished mid-stream.
|
||||
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.send_service_ = DONE_SENDING_SERVICES;
|
||||
this->release_services();
|
||||
conn.park_service_stream_();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -515,19 +431,21 @@ 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;
|
||||
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_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) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_,
|
||||
conn.address_str_, conn.send_service_);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
uint16_t total_char_count = 0;
|
||||
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;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -553,16 +471,12 @@ 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) {
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
service_resp.characteristics.emplace_back();
|
||||
auto &characteristic_resp = service_resp.characteristics.back();
|
||||
@@ -578,7 +492,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.send_service_ = DONE_SENDING_SERVICES;
|
||||
conn.abort_service_stream(desc_count_status);
|
||||
return;
|
||||
}
|
||||
if (total_desc_count > 0) {
|
||||
@@ -589,16 +503,10 @@ 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) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
characteristic_resp.descriptors.emplace_back();
|
||||
auto &descriptor_resp = characteristic_resp.descriptors.back();
|
||||
@@ -621,20 +529,26 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
// On a failed send, rewind the cursor so the batch is retried instead of
|
||||
// silently skipped.
|
||||
if (!api_conn->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_);
|
||||
conn.note_batch_stalled_();
|
||||
conn.send_service_ = batch_start;
|
||||
return;
|
||||
}
|
||||
conn.batch_stalled_ = false;
|
||||
}
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
// ---- 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) {
|
||||
// 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);
|
||||
// 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));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (st != ClientState::CONNECTING) {
|
||||
@@ -644,36 +558,49 @@ 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->report_connection_state_(false, param->open.status);
|
||||
this->listener_->on_connection_state(false, 0, param->open.status);
|
||||
return;
|
||||
}
|
||||
if (this->shim_.disconnect_pending()) {
|
||||
// Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches.
|
||||
if (this->disconnect_pending()) {
|
||||
// Open resolved with a teardown scheduled: close now (conn_id_ stays 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; the MTU report below is
|
||||
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
|
||||
// matching the previous esp32 behavior).
|
||||
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->seen_mtu_ = true;
|
||||
this->report_connection_state_(true, 0);
|
||||
// Settled: only the disconnect safety net needs the loop, and
|
||||
// set_disconnecting_() re-enables it.
|
||||
this->disable_loop();
|
||||
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_();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -685,9 +612,9 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
|
||||
this->set_disconnecting_();
|
||||
}
|
||||
|
||||
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
bool BluedroidGattClient::gattc_event_handler(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->shim_.app_id != param->reg.app_id)
|
||||
if (event == ESP_GATTC_REG_EVT && this->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;
|
||||
@@ -710,6 +637,8 @@ bool BluedroidGattClient::handle_gattc_event_(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;
|
||||
}
|
||||
@@ -725,13 +654,17 @@ bool BluedroidGattClient::handle_gattc_event_(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_) {
|
||||
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.
|
||||
this->seen_mtu_ = true;
|
||||
// The connected report waited for the MTU so HA never sees 23.
|
||||
this->report_connection_state_(true, 0);
|
||||
// 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_();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -748,17 +681,23 @@ bool BluedroidGattClient::handle_gattc_event_(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->report_connection_state_(false, param->close.reason);
|
||||
this->listener_->on_connection_state(false, 0, 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_);
|
||||
this->set_state_(ClientState::ESTABLISHED);
|
||||
this->handle_search_cmpl_();
|
||||
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
|
||||
this->disable_loop();
|
||||
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_();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
@@ -802,13 +741,18 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
void BluedroidGattClient::gap_event_handler(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 server-initiated security request.
|
||||
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);
|
||||
// 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);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
// 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 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.
|
||||
// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in
|
||||
// the tracker-invoked connect() override.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -13,8 +11,6 @@
|
||||
|
||||
#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"
|
||||
@@ -24,16 +20,37 @@
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
class BluedroidGattClient;
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothConnection;
|
||||
#endif
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
public:
|
||||
explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {}
|
||||
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 ----
|
||||
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;
|
||||
@@ -43,26 +60,10 @@ class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient {
|
||||
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 disconnect();
|
||||
int gatt_disconnect();
|
||||
bool cancel_gatt_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);
|
||||
@@ -71,20 +72,14 @@ class BluedroidGattClient final : public Component {
|
||||
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);
|
||||
// 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
|
||||
// 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.
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// 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.
|
||||
@@ -94,71 +89,52 @@ class BluedroidGattClient final : public Component {
|
||||
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);
|
||||
void handle_search_cmpl_();
|
||||
int handle_search_cmpl_(esp_gatt_status_t status);
|
||||
void deliver_pending_search_();
|
||||
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_{0xFFFF};
|
||||
uint16_t mtu_{23};
|
||||
uint16_t conn_id_{UNSET_CONN_ID};
|
||||
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_;
|
||||
// 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};
|
||||
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};
|
||||
// The connected report waits for the MTU exchange; OPEN_EVT alone would
|
||||
// hand HA the default 23.
|
||||
bool seen_mtu_{false};
|
||||
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};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -28,7 +28,8 @@ 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 disconnect() { 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 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,27 @@
|
||||
// The proxy's per-slot connection wrapper, shared by every platform.
|
||||
//
|
||||
// SERVICE STREAMING HAZARD - read before touching the streaming code here or
|
||||
// in the platform streamers (bluetooth_connection_bluedroid.cpp).
|
||||
//
|
||||
// A V3 client caches the service list it receives as the device's complete,
|
||||
// permanent database. Nothing on the wire marks a list as partial, so a
|
||||
// stream that is truncated, has a skipped batch, or is terminated early
|
||||
// would be cached whole and poison every later session with the device.
|
||||
//
|
||||
// The rule: it is always better to send nothing and let the client time out
|
||||
// than to let services-done follow an incomplete stream. Concretely:
|
||||
// - a refused batch rewinds the cursor and is retried, never skipped;
|
||||
// - services-done is sent only after every batch was accepted;
|
||||
// - every interruption (subscriber lost or swapped, backend abort,
|
||||
// bounds-check failure) parks or aborts WITHOUT services-done and drops
|
||||
// any owed done;
|
||||
// - a new GetServices supersedes an owed done, so a stale done can never
|
||||
// land on a fresh request's empty accumulator and cache it as empty.
|
||||
// The client only caches a list terminated by services-done within the same
|
||||
// request; timeouts, disconnects and errors raise instead of caching.
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -16,21 +36,24 @@ static const char *const TAG = "bluetooth_connection";
|
||||
void BluetoothConnection::set_address(uint64_t address) {
|
||||
// Keep the proxy's pre-allocated connections-free message in step
|
||||
this->proxy_->update_address_slot_(this->address_, address);
|
||||
// Slot changing hands: anything owed belonged to the old address. The
|
||||
// choke point for every reassignment, not just reset_connection_()'s path.
|
||||
this->clear_owed_flags_();
|
||||
this->address_ = address;
|
||||
if (address == 0) {
|
||||
this->address_str_[0] = '\0';
|
||||
return;
|
||||
}
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
format_mac_addr_upper(mac, this->address_str_);
|
||||
}
|
||||
|
||||
void BluetoothConnection::start_connect_() {
|
||||
// No connect timeout here: the API client's own timeout or
|
||||
// the api-gone sweep drives disconnect().
|
||||
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().
|
||||
this->state_ = ClientState::CONNECTING;
|
||||
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
|
||||
int err = this->backend_->connect(this->address_, address_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,40 +61,21 @@ void BluetoothConnection::start_connect_() {
|
||||
}
|
||||
|
||||
void BluetoothConnection::disconnect() {
|
||||
// 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).
|
||||
// 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.
|
||||
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
|
||||
return;
|
||||
}
|
||||
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_);
|
||||
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);
|
||||
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) {
|
||||
@@ -92,6 +96,8 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
|
||||
this->state_ = ClientState::IDLE;
|
||||
this->services_discovered_ = false;
|
||||
this->paired_ = false;
|
||||
// Link gone: the slot may hold a different device before the drain runs.
|
||||
this->clear_owed_flags_();
|
||||
this->backend_->release_services();
|
||||
this->proxy_->reset_connection_slot_(this, reason);
|
||||
}
|
||||
@@ -102,26 +108,18 @@ 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.
|
||||
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 ignored: nonzero just means the backend was already idle, and
|
||||
// re-arming a freed slot could clobber a new reservation.
|
||||
this->backend_->gatt_disconnect();
|
||||
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_->disconnect();
|
||||
// Fresh teardown attempt: give it the full safety window.
|
||||
this->disconnecting_started_ = millis();
|
||||
if (err == GATT_NOT_CONNECTED) {
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
// 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;
|
||||
}
|
||||
@@ -130,11 +128,15 @@ 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. 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.
|
||||
// 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.
|
||||
this->state_ = ClientState::ESTABLISHED;
|
||||
// The one D-level line for a cached connect; the uncached path narrates
|
||||
// through "Discovery finished" instead.
|
||||
ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_,
|
||||
this->address_str_, mtu);
|
||||
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
|
||||
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
|
||||
ble_device_base::MEDIUM_CONN_TIMEOUT);
|
||||
@@ -143,19 +145,18 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_,
|
||||
param_err);
|
||||
}
|
||||
this->proxy_->send_device_connection(this->address_, true, mtu);
|
||||
this->send_connected_reply_();
|
||||
this->proxy_->send_connections_free();
|
||||
return;
|
||||
}
|
||||
// V3_WITHOUT_CACHE: discover services first — the connected response is
|
||||
// sent when discovery completes, mirroring the esp32 flow (MTU + services
|
||||
// before the response).
|
||||
// sent when discovery completes (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->pending_error_ = err;
|
||||
this->latch_pending_error_(err);
|
||||
this->disconnect();
|
||||
}
|
||||
return;
|
||||
@@ -174,7 +175,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->pending_error_ = error;
|
||||
this->latch_pending_error_(error);
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
@@ -182,22 +183,136 @@ void BluetoothConnection::on_service_discovery_done(int error) {
|
||||
this->mtu_);
|
||||
this->state_ = ClientState::ESTABLISHED;
|
||||
this->services_discovered_ = true;
|
||||
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
|
||||
this->send_connected_reply_();
|
||||
this->proxy_->send_connections_free();
|
||||
}
|
||||
|
||||
void BluetoothConnection::flush_owed_replies_() {
|
||||
// Connected first: the client should never see services-done or an ack for
|
||||
// a link it has not been told is up. Structural, not size-dependent: a
|
||||
// still-owed connected reply defers the smaller sends to the next tick.
|
||||
if (this->connected_reply_owed_) {
|
||||
this->send_connected_reply_();
|
||||
if (this->connected_reply_owed_) {
|
||||
// The retry limits are wall-clock windows: age the deferred budgets so
|
||||
// a reply cannot outlive the window it was sized for.
|
||||
if (this->send_service_ == SERVICES_DONE_PENDING) {
|
||||
this->age_services_done_();
|
||||
}
|
||||
if (this->has_pending_ack_()) {
|
||||
this->age_pending_ack_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this->send_service_ == SERVICES_DONE_PENDING) {
|
||||
this->send_services_done_();
|
||||
}
|
||||
if (this->has_pending_ack_()) {
|
||||
this->flush_pending_ack_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::send_connected_reply_() {
|
||||
if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) {
|
||||
this->connected_reply_owed_ = false;
|
||||
return;
|
||||
}
|
||||
// Warn on the leading edge only, as elsewhere: the drop must be visible but
|
||||
// must not add traffic to the connection that just refused a frame.
|
||||
if (!this->connected_reply_owed_) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_);
|
||||
this->connected_reply_owed_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_,
|
||||
operation, handle, status);
|
||||
}
|
||||
|
||||
void BluetoothConnection::note_batch_stalled_() {
|
||||
if (this->batch_stalled_)
|
||||
return;
|
||||
this->batch_stalled_ = true;
|
||||
ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_,
|
||||
this->address_str_);
|
||||
}
|
||||
|
||||
/// Both payload-free acks are just (address, handle); only the type differs.
|
||||
template<typename Response>
|
||||
static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) {
|
||||
Response resp;
|
||||
resp.address = address;
|
||||
resp.handle = handle;
|
||||
return api_connection->send_message(resp);
|
||||
}
|
||||
|
||||
/// Sole construction site, so a re-offer cannot drift from the original.
|
||||
bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
|
||||
if (kind == PendingAck::PENDING_ACK_ERROR) {
|
||||
// Proxy owns the error reply and reports a refusal the same way.
|
||||
return this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return true; // Nobody subscribed: nothing is owed
|
||||
switch (kind) {
|
||||
case PendingAck::PENDING_ACK_WRITE:
|
||||
return send_handle_reply<api::BluetoothGATTWriteResponse>(api_connection, this->address_, handle);
|
||||
case PendingAck::PENDING_ACK_NOTIFY:
|
||||
return send_handle_reply<api::BluetoothGATTNotifyResponse>(api_connection, this->address_, handle);
|
||||
case PendingAck::PENDING_ACK_NONE:
|
||||
case PendingAck::PENDING_ACK_ERROR: // returned above
|
||||
return true;
|
||||
}
|
||||
// No default label above, so a new enumerator is a -Wswitch warning rather
|
||||
// than a silent notify reply. This return only satisfies -Wreturn-type.
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
|
||||
if (this->try_send_ack_(kind, handle, error))
|
||||
return;
|
||||
// Report a newly owed reply and a displaced one; displacing is the case
|
||||
// that loses a reply. Re-refusing the same one stays quiet.
|
||||
if (!this->has_pending_ack_()) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_,
|
||||
this->address_str_, handle);
|
||||
} else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_,
|
||||
this->address_str_, this->pending_ack_handle_, handle);
|
||||
}
|
||||
this->latch_pending_ack_(kind, handle, error);
|
||||
}
|
||||
|
||||
void BluetoothConnection::flush_pending_ack_() {
|
||||
if (!this->has_pending_ack_())
|
||||
return;
|
||||
if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) {
|
||||
this->clear_pending_ack_();
|
||||
return;
|
||||
}
|
||||
this->age_pending_ack_();
|
||||
}
|
||||
|
||||
void BluetoothConnection::age_pending_ack_() {
|
||||
if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) {
|
||||
// Undeliverable: past here the client has given up and may have re-asked,
|
||||
// and a late reply would answer the new request instead of this one.
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_,
|
||||
this->address_str_, this->pending_ack_handle_);
|
||||
this->clear_pending_ack_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
|
||||
// Late completion for a freed slot; nothing to report.
|
||||
if (this->address_ == 0)
|
||||
return;
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_("reading char/descriptor", handle, error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
@@ -208,6 +323,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u
|
||||
resp.handle = handle;
|
||||
resp.set_data(data, len);
|
||||
if (!api_connection->send_message(resp)) {
|
||||
// Not latched: would mean holding the payload through the congestion
|
||||
// that refused it. The client's read timeout arbitrates.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
}
|
||||
@@ -217,18 +334,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) {
|
||||
return;
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_("writing char/descriptor", handle, error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTWriteResponse resp;
|
||||
resp.address = this->address_;
|
||||
resp.handle = handle;
|
||||
if (!api_connection->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle);
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) {
|
||||
@@ -237,18 +346,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle,
|
||||
error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTNotifyResponse resp;
|
||||
resp.address = this->address_;
|
||||
resp.handle = handle;
|
||||
if (!api_connection->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle);
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
@@ -263,6 +364,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u
|
||||
resp.handle = handle;
|
||||
resp.set_data(data, len);
|
||||
if (!api_connection->send_message(resp)) {
|
||||
// Not latched, same reason as the read reply. Notify data is lossy: the
|
||||
// peripheral will not resend it.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
}
|
||||
@@ -279,6 +382,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
|
||||
if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -287,6 +391,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
|
||||
|
||||
conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
|
||||
bool response) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
|
||||
if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -294,6 +399,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
|
||||
if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -304,6 +410,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
|
||||
// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP).
|
||||
conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length,
|
||||
bool /*response*/) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
|
||||
if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -311,6 +418,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY);
|
||||
if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_,
|
||||
@@ -327,25 +435,45 @@ 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 {
|
||||
this->age_services_done_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::age_services_done_() {
|
||||
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: 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.
|
||||
// The subscriber vanished mid-stream; 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->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->backend_->release_services();
|
||||
this->park_service_stream_();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -383,8 +511,7 @@ 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->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->disconnect();
|
||||
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
|
||||
return;
|
||||
}
|
||||
if (char_count > 0) {
|
||||
@@ -400,8 +527,7 @@ 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->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->disconnect();
|
||||
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
|
||||
return;
|
||||
}
|
||||
if (desc_count == 0) {
|
||||
@@ -429,11 +555,13 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
// (bounded: a subscriber that stays gone ends streaming via the api-lost
|
||||
// rewind above).
|
||||
if (!api_conn->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_);
|
||||
this->note_batch_stalled_();
|
||||
this->send_service_ = batch_start;
|
||||
return;
|
||||
}
|
||||
this->batch_stalled_ = false;
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
@@ -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_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
#include "bluetooth_connection_gatt_backend.h"
|
||||
@@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection {
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
using ConnectionType = ble_device_base::ConnectionType;
|
||||
|
||||
/// A refused GATT reply owed to the current subscriber. Payload-free only:
|
||||
/// these rebuild from address + handle + error, so a retry costs no buffered
|
||||
/// data. Read and notify-data carry payloads and are deliberately absent.
|
||||
enum class PendingAck : uint8_t {
|
||||
PENDING_ACK_NONE = 0,
|
||||
PENDING_ACK_WRITE,
|
||||
PENDING_ACK_NOTIFY,
|
||||
PENDING_ACK_ERROR,
|
||||
};
|
||||
|
||||
class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
public:
|
||||
/// Wire the platform backend. Called from codegen before setup.
|
||||
@@ -41,14 +51,28 @@ 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);
|
||||
|
||||
/// 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_();
|
||||
/// 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 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(); }
|
||||
@@ -63,8 +87,9 @@ 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;
|
||||
// The bluedroid backend branches on the type itself (prefer-params and
|
||||
// the with-cache report at OPEN_EVT); the others ignore it.
|
||||
// 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.
|
||||
this->backend_->set_connection_type(ct);
|
||||
}
|
||||
// Latched at discovery completion rather than read from the backend table:
|
||||
@@ -73,18 +98,12 @@ 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 and police the disconnect
|
||||
/// safety timeout. Called from the proxy's loop — the wrapper has no
|
||||
/// Component loop of its own.
|
||||
/// Stream any pending service-discovery batch (proxy loop; the backend
|
||||
/// owns the disconnect safety timer).
|
||||
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) ----
|
||||
@@ -101,7 +120,64 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
// The Bluedroid backend streams services in place from its stack cache.
|
||||
friend class BluedroidGattClient;
|
||||
|
||||
void start_connect_();
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Latch a refused reply for the proxy drain. One slot per connection,
|
||||
/// newest wins: a GATT client works one request at a time, and a discarded
|
||||
/// reply falls back to the timeout it would have hit anyway.
|
||||
void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) {
|
||||
this->pending_ack_retries_ = 0;
|
||||
this->pending_ack_ = kind;
|
||||
this->pending_ack_handle_ = handle;
|
||||
this->pending_ack_error_ = error;
|
||||
}
|
||||
void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; }
|
||||
/// Drop an owed reply this re-ask makes stale. Clients match futures on
|
||||
/// response type as well as handle, so an owed error (which resolves any op
|
||||
/// on the handle) is cleared by any re-ask, other kinds only by their own.
|
||||
void supersede_pending_ack_(uint16_t handle, PendingAck kind) {
|
||||
if (this->has_pending_ack_() && this->pending_ack_handle_ == handle &&
|
||||
(this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) {
|
||||
this->clear_pending_ack_();
|
||||
}
|
||||
}
|
||||
bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; }
|
||||
/// Warn on the stall's leading edge only. The batch is never lost (the
|
||||
/// caller rewinds the cursor), and a warning per attempt would add traffic
|
||||
/// to the connection already refusing frames. Both streamers route here.
|
||||
void note_batch_stalled_();
|
||||
/// Send the connected=true reply, latching it if the API refuses. Rebuilt
|
||||
/// from address_ and mtu_, so the latch is one bit; a dropped confirmation
|
||||
/// leaves the client timing out while this slot holds a live link. No retry
|
||||
/// bound: the slot's lifetime is the bound (teardown clears the flag).
|
||||
void send_connected_reply_();
|
||||
/// Re-offer everything this slot owes. One entry point so the proxy drain
|
||||
/// does not have to know which latches exist.
|
||||
void flush_owed_replies_();
|
||||
/// Drop everything this slot owes, in one write to the shared tail byte.
|
||||
void clear_owed_flags_() {
|
||||
this->pending_ack_ = PendingAck::PENDING_ACK_NONE;
|
||||
this->batch_stalled_ = false;
|
||||
this->connected_reply_owed_ = false;
|
||||
}
|
||||
/// Sole construction site for these replies, shared by send and retry.
|
||||
bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error);
|
||||
/// First attempt: send, and latch it for the drain if the API refuses.
|
||||
void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0);
|
||||
/// Report a rejected request. Latched like a completion reply, so a
|
||||
/// refused frame does not strand the client for its whole timeout.
|
||||
void send_gatt_error_(uint16_t handle, conn_err_t error) {
|
||||
this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error);
|
||||
}
|
||||
/// Re-offer the owed reply; clears on success, stays owed on a refusal.
|
||||
void flush_pending_ack_();
|
||||
/// Advance the retry budget and abandon at the limit, without sending.
|
||||
void age_pending_ack_();
|
||||
// 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
|
||||
@@ -113,8 +189,26 @@ 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_() {
|
||||
this->batch_stalled_ = false;
|
||||
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_();
|
||||
void check_disconnect_timeout_();
|
||||
/// 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_();
|
||||
/// Advance the retry budget and abandon at the limit, without sending.
|
||||
void age_services_done_();
|
||||
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);
|
||||
@@ -124,27 +218,57 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
|
||||
ble_device_base::BLEGattConnection *backend_{nullptr};
|
||||
|
||||
// Group 2: 2-byte types
|
||||
// Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays
|
||||
// 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8).
|
||||
int16_t send_service_{INIT_SENDING_SERVICES};
|
||||
uint16_t mtu_{23};
|
||||
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
|
||||
|
||||
// Group 3: 8-byte and 4-byte types
|
||||
uint64_t address_{0};
|
||||
uint32_t disconnecting_started_{0};
|
||||
conn_err_t pending_error_{0};
|
||||
// Full width: the GATT error domain is open-ended (ble_gatt_client.h) and
|
||||
// forwarded untranslated, so narrowing would corrupt platform codes.
|
||||
conn_err_t pending_ack_error_{0};
|
||||
|
||||
// Group 4: Arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
// Parked here rather than in Group 2: address_str_ ends 2-aligned, so this
|
||||
// uses tail slack instead of pushing address_ out by 6 bytes of padding.
|
||||
uint16_t pending_ack_handle_{0};
|
||||
|
||||
// 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};
|
||||
// Group 5: bit-packed tail. The first two bytes were already full, so the
|
||||
// first added bit forced a third and took the 8-aligned object 48 -> 56;
|
||||
// the handle, error and retry counter ride in that padding. Four bitfield
|
||||
// bits left; another byte-sized member costs 8 per slot.
|
||||
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 first 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};
|
||||
static_assert(static_cast<uint8_t>(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow");
|
||||
PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE};
|
||||
/// Set while a refused batch is retrying, so only the first one warns.
|
||||
bool batch_stalled_ : 1 {false};
|
||||
/// An owed connected=true reply; the proxy's paced drain re-offers it.
|
||||
bool connected_reply_owed_ : 1 {false};
|
||||
// Plain byte after the bitfields: takes the padding byte instead of
|
||||
// straddling pending_ack_'s storage unit and growing the object.
|
||||
static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow");
|
||||
uint8_t pending_ack_retries_{0};
|
||||
};
|
||||
|
||||
// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad
|
||||
// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit.
|
||||
static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56,
|
||||
"BluetoothConnection layout regressed on a 32-bit target");
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#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>
|
||||
@@ -25,6 +26,13 @@ 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;
|
||||
|
||||
@@ -53,6 +61,7 @@ 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]) {
|
||||
@@ -83,6 +92,7 @@ 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
|
||||
@@ -95,9 +105,24 @@ 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)"); }
|
||||
@@ -123,34 +148,56 @@ 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;
|
||||
}
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
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: {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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));
|
||||
if (inst != nullptr) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
|
||||
}
|
||||
@@ -392,44 +439,73 @@ 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, "Dropped %u GATT control events, disconnecting", dropped);
|
||||
this->disconnect();
|
||||
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
|
||||
if (notify_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
|
||||
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
|
||||
}
|
||||
|
||||
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
if (this->state_ == EngineState::CONNECT_PENDING) {
|
||||
uint32_t now = millis();
|
||||
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
|
||||
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;
|
||||
// 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.
|
||||
gap_connect_cancel();
|
||||
// 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);
|
||||
cancel_sent = !this->connect_cancel_attempted_;
|
||||
}
|
||||
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 {
|
||||
// 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();
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::DISCONNECTING) {
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
|
||||
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
|
||||
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
|
||||
@@ -445,7 +521,7 @@ void RP2GattClient::loop() {
|
||||
}
|
||||
}
|
||||
if (timed_out) {
|
||||
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
|
||||
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, 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_() &&
|
||||
@@ -466,7 +542,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, "MTU %u", this->mtu_);
|
||||
ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
|
||||
this->state_ = EngineState::READY;
|
||||
// Scanning resumes and runs alongside the established connection.
|
||||
this->release_scan_inhibit_();
|
||||
@@ -514,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
return;
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
|
||||
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
|
||||
this->fail_connection_(status);
|
||||
return;
|
||||
}
|
||||
@@ -538,7 +614,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, "Link up, handle=0x%04x, negotiating MTU", con_handle);
|
||||
ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
|
||||
BluetoothLock lock;
|
||||
// One wildcard listener covers notifications/indications for every
|
||||
// characteristic on this connection; the CCCD writes come from the API
|
||||
@@ -563,6 +639,24 @@ 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;
|
||||
@@ -576,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() {
|
||||
while ((stale = this->notify_queue_.pop()) != nullptr) {
|
||||
this->notify_pool_.release(stale);
|
||||
}
|
||||
// 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) {
|
||||
// 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).
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
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;
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
this->notify_subscription_count_ = 0;
|
||||
this->cancel_requested_ = false;
|
||||
this->op_type_ = OpType::NONE;
|
||||
@@ -595,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
|
||||
if (this->state_ == EngineState::IDLE) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
|
||||
ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
|
||||
this->fail_connection_(reason);
|
||||
}
|
||||
|
||||
@@ -653,7 +752,7 @@ int RP2GattClient::discover_services() {
|
||||
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
|
||||
this->arena_ = allocator.allocate(1);
|
||||
if (this->arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Service table allocation failed");
|
||||
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
new (this->arena_) ServiceArena();
|
||||
@@ -759,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
|
||||
|
||||
void RP2GattClient::finish_discovery_(int error) {
|
||||
this->discovery_phase_ = DiscoveryPhase::NONE;
|
||||
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
|
||||
this->char_count_, this->desc_count_);
|
||||
ESP_LOGV(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_);
|
||||
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.
|
||||
@@ -838,30 +937,80 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
this->parent_->inhibit_scan();
|
||||
this->connect_cancel_attempted_ = false;
|
||||
this->cancel_requested_ = false;
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int RP2GattClient::disconnect() {
|
||||
// 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() {
|
||||
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
|
||||
@@ -870,9 +1019,18 @@ int RP2GattClient::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;
|
||||
gap_connect_cancel();
|
||||
// Completion arrives as a failed connection-complete event.
|
||||
// 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();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -880,20 +1038,23 @@ int RP2GattClient::disconnect() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 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);
|
||||
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
{
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
this->state_ = EngineState::DISCONNECTING;
|
||||
this->disconnecting_started_ = millis();
|
||||
@@ -1098,7 +1259,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_
|
||||
}
|
||||
|
||||
conn_err_t unpair_device(uint64_t address) {
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
bool found = false;
|
||||
BluetoothLock lock;
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
#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>
|
||||
@@ -71,7 +75,13 @@ 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> {
|
||||
class RP2GattClient final : public Component,
|
||||
public Parented<rp2040_ble::RP2040BLE>
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -82,7 +92,10 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
|
||||
// ---- ble_device_base::BLEGattConnection contract ----
|
||||
int connect(uint64_t address, uint8_t addr_type);
|
||||
int disconnect();
|
||||
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 discover_services();
|
||||
int read_characteristic(uint16_t handle);
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
@@ -92,18 +105,26 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
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();
|
||||
// No connection-type branching on this backend.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) {}
|
||||
// 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; }
|
||||
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,
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
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
|
||||
DISCONNECTING,
|
||||
};
|
||||
|
||||
@@ -140,6 +161,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
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);
|
||||
@@ -168,13 +190,17 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
|
||||
// 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_{23};
|
||||
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
|
||||
uint16_t op_handle_{0};
|
||||
uint16_t op_len_{0};
|
||||
uint16_t service_count_{0};
|
||||
@@ -188,8 +214,9 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
// 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};
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
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};
|
||||
EngineState state_{EngineState::IDLE};
|
||||
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
|
||||
OpType op_type_{OpType::NONE};
|
||||
@@ -211,6 +238,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_ID,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2,
|
||||
@@ -47,14 +48,13 @@ 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. 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).
|
||||
# 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).
|
||||
# Coupled to bluetooth_connection: platforms with a GATT backend are also
|
||||
# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry.
|
||||
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
|
||||
# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and
|
||||
# FILTER_SOURCE_FILES hub entry.
|
||||
_HUB_PLATFORMS = (PLATFORM_BK72XX, 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]
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
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. The slot limit comes from the
|
||||
prebuilt BTstack library (one connection today); the code is built for N."""
|
||||
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."""
|
||||
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
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
|
||||
)(config)
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [
|
||||
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
|
||||
],
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(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 framework's BTstack library is built with "
|
||||
f"MAX_NR_GATT_CLIENTS {max_conn}",
|
||||
"the BTstack pool overrides in rp2040_ble are sized "
|
||||
f"for {max_conn}",
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -203,10 +203,18 @@ 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()."""
|
||||
for connection_conf in config.get(CONF_CONNECTIONS, []):
|
||||
backend = await bluetooth_connection.new_gatt_backend(
|
||||
connection_conf, service_table=False
|
||||
)
|
||||
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))
|
||||
if connections:
|
||||
# Gates the connection and GATT half of the API surface. A proxy
|
||||
# without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never
|
||||
# sends those requests and their handlers and encoders are dead.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
cg.add(connection.set_backend(backend))
|
||||
cg.add(var.register_connection(connection))
|
||||
@@ -263,11 +271,15 @@ 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 "
|
||||
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
|
||||
"family (advertisement-only)."
|
||||
f"platform. It runs on {full} (full proxy) and {adv_only} "
|
||||
"(advertisement-only)."
|
||||
)
|
||||
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
|
||||
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
|
||||
@@ -366,10 +378,6 @@ 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):
|
||||
@@ -384,12 +392,6 @@ 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 false;
|
||||
return true; // Nobody subscribed: nothing owed
|
||||
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,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
|
||||
return this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#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
|
||||
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
|
||||
@@ -62,12 +67,13 @@ 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.
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
#endif
|
||||
|
||||
// Capture the configured scan mode from YAML before any API changes
|
||||
this->configured_scan_active_ = this->hub_->scan_active();
|
||||
@@ -78,7 +84,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_bluetooth_scanner_state_(state);
|
||||
static_cast<BluetoothProxy *>(self)->send_scanner_state_(state);
|
||||
}});
|
||||
#endif
|
||||
}
|
||||
@@ -98,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
|
||||
this->response_.advertisements_len++;
|
||||
|
||||
ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
|
||||
ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
|
||||
|
||||
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
|
||||
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
|
||||
@@ -106,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
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));
|
||||
@@ -115,7 +121,20 @@ 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_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
|
||||
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
|
||||
@@ -124,22 +143,32 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ
|
||||
void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action,
|
||||
const char *type) {
|
||||
this->log_not_connected_gatt_(action, type);
|
||||
this->send_gatt_error(address, handle, GATT_NOT_CONNECTED);
|
||||
if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) {
|
||||
// No connection, so nothing to latch against; the client's timeout arbitrates.
|
||||
this->log_reply_dropped_("Not-connected", address);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void BluetoothProxy::log_advertisement_flush_() {
|
||||
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
void BluetoothProxy::log_advertisement_flush_(bool sent) {
|
||||
if (sent) {
|
||||
// VV: one line per flush drowns a verbose log in any busy environment.
|
||||
ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
} else {
|
||||
// The rare congestion signal stays at V.
|
||||
ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len);
|
||||
}
|
||||
}
|
||||
|
||||
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[18];
|
||||
char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
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_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Active: %s\n"
|
||||
@@ -157,12 +186,9 @@ void BluetoothProxy::dump_config() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
// 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) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
void BluetoothProxy::register_connection(BluetoothConnection *connection) {
|
||||
if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
// Cannot happen with codegen-sized registration; a silent drop would
|
||||
// surface later as a null proxy_ dereference, so refuse loudly.
|
||||
@@ -173,7 +199,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c
|
||||
connection->connection_index_ = this->connection_count_;
|
||||
this->connections_[this->connection_count_++] = connection;
|
||||
connection->proxy_ = this;
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); }
|
||||
@@ -190,33 +215,104 @@ 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.
|
||||
PendingReply *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) {
|
||||
this->log_reply_deferred_("Disconnect", address);
|
||||
free_entry->set(address, error);
|
||||
return;
|
||||
}
|
||||
// Every entry is owed: evict the first so the newest loss is not silent too.
|
||||
this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), 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();
|
||||
return; // latch_pending_disconnection_ keeps at most one entry per address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::answer_device_disconnected_(uint64_t address) {
|
||||
if (this->send_device_connection(address, false)) {
|
||||
// A landed answer satisfies any owed notification for the address; a
|
||||
// drained duplicate would follow it otherwise.
|
||||
this->clear_pending_disconnection_(address);
|
||||
return;
|
||||
}
|
||||
// Not latched: the client's own request timeout arbitrates, and pooling
|
||||
// these would let a request retry loop displace an unsolicited disconnect.
|
||||
this->log_reply_dropped_("Disconnect", address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) {
|
||||
if (this->send_device_connection(address, false, 0, error)) {
|
||||
// A later disconnect landing for an address that still has one owed would
|
||||
// otherwise have the drain repeat it.
|
||||
this->clear_pending_disconnection_(address);
|
||||
return;
|
||||
}
|
||||
// A dropped disconnect leaves the client believing the link is live, so
|
||||
// every GATT operation on it times out until something else corrects it.
|
||||
// latch_pending_disconnection_() reports the leading edge.
|
||||
this->latch_pending_disconnection_(address, error);
|
||||
}
|
||||
|
||||
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
|
||||
this->send_device_connection(connection->get_address(), false, 0, reason);
|
||||
// The client has no other way to learn of an unsolicited disconnect.
|
||||
this->send_device_disconnected_(connection->get_address(), 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)
|
||||
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);
|
||||
if (conn_addr == address) {
|
||||
// A connect request supersedes an owed disconnect.
|
||||
if (reserve) {
|
||||
this->clear_pending_disconnection_(address);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (free_slot == nullptr && conn_addr == 0)
|
||||
free_slot = connection;
|
||||
}
|
||||
return nullptr;
|
||||
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;
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
@@ -226,20 +322,24 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
auto *connection = this->get_connection_(msg.address, true);
|
||||
if (connection == nullptr) {
|
||||
ESP_LOGW(TAG, "No free connections available");
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
return;
|
||||
}
|
||||
if (!msg.has_address_type) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(),
|
||||
connection->address_str());
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
return;
|
||||
}
|
||||
if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) {
|
||||
this->log_connection_request_ignored_(connection, connection->state());
|
||||
this->send_device_connection(msg.address, true);
|
||||
connection->send_connected_reply_();
|
||||
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.
|
||||
@@ -260,7 +360,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: {
|
||||
auto *connection = this->get_connection_(msg.address, false);
|
||||
if (connection == nullptr) {
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
this->send_connections_free();
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +368,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
connection->disconnect();
|
||||
} else {
|
||||
connection->set_address(0);
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
this->send_connections_free();
|
||||
}
|
||||
break;
|
||||
@@ -312,7 +412,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
|
||||
ESP_LOGE(TAG, "V1 connections removed");
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms
|
||||
|
||||
auto err = connection->read_characteristic(msg.handle);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &
|
||||
|
||||
auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead
|
||||
|
||||
auto err = connection->read_descriptor(msg.handle);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +466,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri
|
||||
|
||||
auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +478,30 @@ 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());
|
||||
this->send_gatt_services_done(msg.address);
|
||||
// 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;
|
||||
return;
|
||||
}
|
||||
if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet
|
||||
@@ -394,14 +517,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
|
||||
|
||||
auto err = connection->notify_characteristic(msg.handle, msg.enable);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
|
||||
// Not latched (esp32 parity): the request is idempotent, so a drop resolves
|
||||
// via the client timeout and a retry gives the same answer. Still reported.
|
||||
|
||||
auto *connection = this->get_connection_(msg.address, false);
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
@@ -412,7 +536,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
connection ? static_cast<int>(connection->get_connection_index()) : -1,
|
||||
connection ? connection->address_str() : "unknown");
|
||||
resp.error = GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
if (!this->api_connection_->send_message(resp)) {
|
||||
this->log_reply_dropped_("Connection-params", msg.address);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -423,10 +549,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
static_cast<uint16_t>(std::min(msg.max_interval, max_val)),
|
||||
static_cast<uint16_t>(std::min(msg.latency, max_val)),
|
||||
static_cast<uint16_t>(std::min(msg.timeout, max_val)));
|
||||
this->api_connection_->send_message(resp);
|
||||
if (!this->api_connection_->send_message(resp)) {
|
||||
this->log_reply_dropped_("Connection-params", msg.address);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -469,7 +597,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// 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++) {
|
||||
@@ -483,8 +611,19 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
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. Every build
|
||||
// sends this at subscribe time (api_connection.cpp), so the drain
|
||||
// compiles on every proxy build.
|
||||
this->connections_free_pending_ = false;
|
||||
this->send_connections_free(this->api_connection_);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// 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++) {
|
||||
@@ -497,7 +636,37 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// 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++) {
|
||||
this->connections_[i]->flush_owed_replies_();
|
||||
}
|
||||
// Address-keyed, not slot-keyed, so it gets its own loop; bounded by
|
||||
// connection_count_ like the latch and clear helpers. Not pre-cleared:
|
||||
// the sender clears on success and re-latches on refusal, keeping the
|
||||
// latch's leading-edge warn honest (same shape as the unpair drain).
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto &owed = this->pending_disconnections_[i];
|
||||
if (owed.empty())
|
||||
continue;
|
||||
this->send_device_disconnected_(owed.address(), owed.error());
|
||||
}
|
||||
|
||||
// An owed unpair reply. Not pre-cleared: the sender clears on success and
|
||||
// re-latches on refusal, keeping its leading-edge warn guard honest.
|
||||
if (!this->pending_unpairing_.empty()) {
|
||||
conn_err_t error = this->pending_unpairing_.error();
|
||||
this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error);
|
||||
}
|
||||
#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
|
||||
// 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_) {
|
||||
@@ -505,100 +674,79 @@ void BluetoothProxy::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
|
||||
#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
|
||||
// connection-oriented request is answered with a clean error instead of
|
||||
// silence, and Home Assistant treats the proxy as passive.
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
switch (msg.request_type) {
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
|
||||
ESP_LOGW(TAG, "Active connections are not supported on this platform");
|
||||
this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
|
||||
// Not an error: the device is already disconnected, which is the requested state.
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->send_connections_free();
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
|
||||
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
|
||||
// Address-scoped maintenance needs no connection slot: real on esp32
|
||||
// (Bluedroid bond table), the stub elsewhere keeps the old error reply.
|
||||
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
|
||||
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
|
||||
break;
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
|
||||
conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address);
|
||||
this->send_device_clear_cache(msg.address, ret == CONN_OK, ret);
|
||||
break;
|
||||
#ifdef USE_WIFI
|
||||
// Wi-Fi (or a coexistence build that can fall back to it): every other
|
||||
// non-empty 100 ms tick (~200 ms) gives partial batches time to fill
|
||||
// toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer,
|
||||
// fuller frames. Full batches still ship immediately from the queueing
|
||||
// path, and the owed-reply drains above keep the 100 ms cadence.
|
||||
if (this->response_.advertisements_len != 0) {
|
||||
if (this->adv_flush_toggle_) {
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
this->adv_flush_toggle_ = !this->adv_flush_toggle_;
|
||||
} else {
|
||||
// Nothing pending (idle, or a full batch just shipped inline): arm so
|
||||
// the next batch ships on the next tick.
|
||||
this->adv_flush_toggle_ = true;
|
||||
}
|
||||
#else
|
||||
// No Wi-Fi in the build (ethernet): no airtime worth trading latency for,
|
||||
// so partial batches flush every tick.
|
||||
this->flush_pending_advertisements_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
|
||||
void BluetoothProxy::reset_owed_replies_() {
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->connections_free_pending_ = false;
|
||||
#endif
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// Owed on unsubscribe; on subscribe the trailing send_scanner_state_()
|
||||
// re-drives it from the hub, so clearing it there is free.
|
||||
this->scanner_state_pending_ = false;
|
||||
#else
|
||||
// Force a poll-arm mismatch: a frame refused at subscribe time could
|
||||
// otherwise match the stale detector and never be retried. Inert on
|
||||
// unsubscribe: loop() returns at the no-subscriber gate before the
|
||||
// detector runs, and a re-subscribe re-arms this anyway.
|
||||
this->last_scan_running_ = !this->hub_->scan_running();
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->pending_unpairing_.clear();
|
||||
this->pending_disconnections_.fill({});
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
// Neither a partial stream's tail nor an owed done belongs to the next
|
||||
// session; silence (the client's timeout) arbitrates.
|
||||
auto *connection = this->connections_[i];
|
||||
connection->park_service_stream_();
|
||||
connection->clear_owed_flags_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
resp.address = msg.address;
|
||||
resp.error = GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
|
||||
// A previous subscriber still holds the slot. This is almost always a stale
|
||||
// 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));
|
||||
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->reset_owed_replies_();
|
||||
}
|
||||
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_bluetooth_scanner_state_(this->hub_->get_scanner_state());
|
||||
this->send_scanner_state_(this->hub_->get_scanner_state());
|
||||
#else
|
||||
this->send_polled_scanner_state_();
|
||||
#endif
|
||||
@@ -610,18 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_owed_replies_();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void BluetoothProxy::send_connections_free() {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
this->send_connections_free(this->api_connection_);
|
||||
@@ -629,25 +769,42 @@ void BluetoothProxy::send_connections_free() {
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) {
|
||||
api_connection->send_message(this->connections_free_response_);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
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
|
||||
api::BluetoothGATTGetServicesDoneResponse call;
|
||||
call.address = address;
|
||||
this->api_connection_->send_message(call);
|
||||
return this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
|
||||
bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
|
||||
api::BluetoothGATTErrorResponse call;
|
||||
call.address = address;
|
||||
call.handle = handle;
|
||||
call.error = error;
|
||||
this->api_connection_->send_message(call);
|
||||
return this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) {
|
||||
@@ -658,22 +815,46 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err
|
||||
call.paired = paired;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (!this->api_connection_->send_message(call)) {
|
||||
// Not latched: a retried PAIR is answered from is_paired(), so the client
|
||||
// recovers on its own. Still worth saying it happened.
|
||||
this->log_reply_dropped_("Pairing", address);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// An owed success is the authoritative answer: a later attempt for the
|
||||
// same address fails only because the first already removed the bond.
|
||||
if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) &&
|
||||
this->pending_unpairing_.error() == CONN_OK) {
|
||||
success = true;
|
||||
error = CONN_OK;
|
||||
}
|
||||
api::BluetoothDeviceUnpairingResponse call;
|
||||
call.address = address;
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (this->api_connection_->send_message(call)) {
|
||||
// A later unpair landing for an address that still has one owed would
|
||||
// otherwise have the drain repeat it.
|
||||
if (this->pending_unpairing_.matches(address)) {
|
||||
this->pending_unpairing_.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this->pending_unpairing_.empty()) {
|
||||
this->log_reply_deferred_("Unpair", address);
|
||||
} else if (!this->pending_unpairing_.matches(address)) {
|
||||
this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address);
|
||||
}
|
||||
this->pending_unpairing_.set(address, error);
|
||||
}
|
||||
|
||||
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
|
||||
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
|
||||
// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE,
|
||||
// so its response encoder would be dead weight there.
|
||||
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
@@ -682,8 +863,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (!this->api_connection_->send_message(call)) {
|
||||
// Not latched: clear-cache is idempotent, so a retry gives the same answer.
|
||||
this->log_reply_dropped_("Clear-cache", address);
|
||||
}
|
||||
}
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#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"
|
||||
|
||||
@@ -24,9 +25,11 @@ 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_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
#endif
|
||||
@@ -57,8 +60,45 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
|
||||
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
|
||||
};
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit
|
||||
/// error on top. Every error that reaches it fits int16_t.
|
||||
class PendingReply {
|
||||
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: only (address 0, error 0) reads back as nothing owed.
|
||||
// A zero-address failure still latches, which is correct - that reply is
|
||||
// owed too. Neither backend can unpair address 0 successfully.
|
||||
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_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
|
||||
PendingReply p;
|
||||
p.set(address, error);
|
||||
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
|
||||
}
|
||||
static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
|
||||
static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
|
||||
static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
|
||||
static_assert(PendingReply{}.empty());
|
||||
#endif
|
||||
|
||||
class BluetoothProxy final : public Component {
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Allow the connection to update connections_free_response_
|
||||
friend bluetooth_connection::BluetoothConnection;
|
||||
#endif
|
||||
@@ -69,9 +109,9 @@ class BluetoothProxy final : public Component {
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void register_connection(BluetoothConnection *connection);
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#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
|
||||
@@ -80,6 +120,7 @@ class BluetoothProxy final : public Component {
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
|
||||
#endif // !USE_ESP32
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
|
||||
void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg);
|
||||
@@ -88,6 +129,7 @@ class BluetoothProxy final : public Component {
|
||||
void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg);
|
||||
void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg);
|
||||
void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg);
|
||||
#endif
|
||||
|
||||
void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags);
|
||||
void unsubscribe_api_connection(api::APIConnection *api_connection);
|
||||
@@ -97,14 +139,23 @@ class BluetoothProxy final : public Component {
|
||||
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
|
||||
}
|
||||
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// False only when a subscriber refused the frame; true = delivered or
|
||||
/// nobody subscribed. Refusals latch in send_device_disconnected_() and
|
||||
/// send_connected_reply_(); other callers report via log_reply_dropped_().
|
||||
bool 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);
|
||||
void send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
|
||||
/// Same convention as send_device_connection: false only on a refused frame.
|
||||
bool send_gatt_services_done(uint64_t address);
|
||||
/// False only when the API refused the frame, so the reply is still owed.
|
||||
bool 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);
|
||||
/// No default error: the drain rebuilds success as (error == CONN_OK), so a
|
||||
/// caller that omitted it would have a reported failure resent as a success.
|
||||
void send_device_unpairing(uint64_t address, bool success, conn_err_t error);
|
||||
void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK);
|
||||
#endif
|
||||
|
||||
void bluetooth_scanner_set_mode(bool active);
|
||||
|
||||
@@ -158,8 +209,8 @@ class BluetoothProxy final : public Component {
|
||||
return flags;
|
||||
}
|
||||
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
|
||||
uint8_t mac[6] = {};
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> output) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE] = {};
|
||||
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.
|
||||
@@ -172,7 +223,9 @@ class BluetoothProxy final : public Component {
|
||||
|
||||
protected:
|
||||
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
void send_scanner_state_(ble_device_base::ScannerState state);
|
||||
#else
|
||||
void send_polled_scanner_state_();
|
||||
#endif
|
||||
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
|
||||
@@ -181,30 +234,27 @@ class BluetoothProxy final : public Component {
|
||||
void flush_pending_advertisements_() {
|
||||
if (this->response_.advertisements_len == 0)
|
||||
return;
|
||||
this->api_connection_->send_message(this->response_);
|
||||
// Perishable and the highest-frequency send here: a drop only reports at
|
||||
// V, anything louder would be the flood the batch pacing exists to avoid.
|
||||
[[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
this->log_advertisement_flush_();
|
||||
this->log_advertisement_flush_(sent);
|
||||
#endif
|
||||
this->response_.advertisements_len = 0;
|
||||
}
|
||||
void log_advertisement_flush_();
|
||||
void log_advertisement_flush_(bool sent);
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
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);
|
||||
#endif
|
||||
void log_not_connected_gatt_(const char *action, const char *type);
|
||||
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
|
||||
|
||||
#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().
|
||||
// maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the
|
||||
// body is removed, and the free < MAX compare would trip -Wtype-limits.
|
||||
void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) {
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
void update_address_slot_(uint64_t old_address, uint64_t new_address) {
|
||||
auto &resp = this->connections_free_response_;
|
||||
if (new_address == 0 && old_address != 0) {
|
||||
if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
@@ -221,7 +271,6 @@ class BluetoothProxy final : public Component {
|
||||
}
|
||||
this->replace_allocated_slot_(0, new_address);
|
||||
}
|
||||
#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
}
|
||||
void replace_allocated_slot_(uint64_t find_value, uint64_t set_value);
|
||||
void log_slot_accounting_mismatch_();
|
||||
@@ -231,32 +280,85 @@ 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);
|
||||
/// Send connected=false and pool it for the paced drain if refused. A
|
||||
/// dropped disconnect desynchronises the proxy: the client keeps a link it
|
||||
/// believes is live and every operation on it times out. Unsolicited and
|
||||
/// drained notifications only; request answers use the variant below.
|
||||
void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK);
|
||||
/// Answer a request with connected=false. Never pools: a refusal falls back
|
||||
/// to the client's request timeout, keeping the pool for the unsolicited
|
||||
/// notifications the client cannot recover on its own.
|
||||
void answer_device_disconnected_(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
|
||||
|
||||
/// Drop everything the ending session was owed. One list, so a new latch is
|
||||
/// one edit rather than two call sites where an omission looks deliberate.
|
||||
/// Drops state only, never sends: api_connection_ is the departing
|
||||
/// subscriber on subscribe and nullptr on unsubscribe.
|
||||
void reset_owed_replies_();
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// Report a reply we deliberately do not latch, so no drop is silent.
|
||||
void log_reply_dropped_(const char *what, uint64_t address);
|
||||
/// A latched reply's leading edge; the drain's re-refusals stay quiet.
|
||||
void log_reply_deferred_(const char *what, uint64_t address);
|
||||
/// A latched reply lost to a newer one for a different address.
|
||||
void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address);
|
||||
#endif
|
||||
|
||||
// Memory optimized layout for 32-bit systems
|
||||
// Group 1: Pointers (4 bytes each, naturally aligned)
|
||||
api::APIConnection *api_connection_{nullptr};
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// 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<PendingReply, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
|
||||
// Owed unpair reply. The bond is already gone when the send is refused, so
|
||||
// a retry is told the unpair failed when it succeeded. One slot: a second
|
||||
// refused unpair displaces the first, as happened to both before this.
|
||||
PendingReply pending_unpairing_{};
|
||||
#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};
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Pre-allocated response message - always ready to send
|
||||
api::BluetoothConnectionsFreeResponse connections_free_response_;
|
||||
#endif
|
||||
|
||||
// Group 4: 1-byte types grouped together
|
||||
bool active_;
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// 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};
|
||||
#endif
|
||||
bool configured_scan_active_{false}; // Configured scan mode from YAML
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#ifdef USE_WIFI
|
||||
/// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial
|
||||
/// batches fill; an idle tick re-arms, so the first batch after a gap
|
||||
/// still ships on the next tick. See loop().
|
||||
bool adv_flush_toggle_{false};
|
||||
#endif
|
||||
#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
|
||||
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import core, external_files
|
||||
@@ -12,6 +11,8 @@ from esphome.const import (
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_TEMPERATURE_OFFSET,
|
||||
)
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@neffs", "@kbx81"]
|
||||
CONFLICTS_WITH = ["bme680_bsec"]
|
||||
@@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = {
|
||||
|
||||
|
||||
def _compute_local_file_path(url: str) -> Path:
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def _compute_url(config: dict) -> str:
|
||||
@@ -105,6 +102,42 @@ def download_bme68x_blob(config):
|
||||
return config
|
||||
|
||||
|
||||
# Shared by the schema and the prefetch hook so they cannot drift.
|
||||
_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True)
|
||||
_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True)
|
||||
# Key -> (validator, default) for the defaulted options that select the blob.
|
||||
_BLOB_OPTIONS = {
|
||||
CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"),
|
||||
CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"),
|
||||
CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
"""Raw entry to its BSEC2 blob; None when a value is unrecognized.
|
||||
|
||||
Applies the schema defaults and validators read-only; skipped entries
|
||||
are left to the schema validator.
|
||||
"""
|
||||
try:
|
||||
spec = {
|
||||
key: validator(str(entry.get(key, default))) # pylint: disable=not-callable
|
||||
for key, (validator, default) in _BLOB_OPTIONS.items()
|
||||
}
|
||||
spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, "")))
|
||||
if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None:
|
||||
spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR(
|
||||
str(algorithm_output)
|
||||
)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
url = _compute_url(spec)
|
||||
return RemoteFile(url, _compute_local_file_path(url))
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref)
|
||||
|
||||
|
||||
def validate_bme68x(config):
|
||||
if CONF_ALGORITHM_OUTPUT not in config:
|
||||
return config
|
||||
@@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = (
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BME68xBSEC2Component),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True),
|
||||
cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum(
|
||||
ALGORITHM_OUTPUT_OPTIONS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum(
|
||||
OPERATING_AGE_OPTIONS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum(
|
||||
SAMPLE_RATE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
|
||||
VOLTAGE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Required(CONF_MODEL): _MODEL_VALIDATOR,
|
||||
cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR,
|
||||
**{
|
||||
cv.Optional(key, default=default): validator
|
||||
for key, (validator, default) in _BLOB_OPTIONS.items()
|
||||
},
|
||||
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
|
||||
cv.Optional(
|
||||
CONF_STATE_SAVE_INTERVAL, default="6hours"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components import bme68x_bsec2, i2c
|
||||
from esphome.components.bme68x_bsec2 import (
|
||||
CONFIG_SCHEMA_BASE,
|
||||
BME68xBSEC2Component,
|
||||
@@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
MULTI_CONF = True
|
||||
|
||||
# The user-facing domain is this module (the base component only appears
|
||||
# via AUTO_LOAD), so the batch-download hook must be re-exported here to
|
||||
# take effect.
|
||||
PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES
|
||||
|
||||
bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c")
|
||||
BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_(
|
||||
"BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice
|
||||
|
||||
@@ -103,7 +103,8 @@ struct CameraImageSpec {
|
||||
/** Abstract camera base class. Collaborates with API.
|
||||
* 1) API server starts and registers as a listener (add_listener)
|
||||
* to receive new images from the camera.
|
||||
* 2) New API client connects and creates a new image reader (create_image_reader).
|
||||
* 2) API connection creates an image reader (create_image_reader) when it receives
|
||||
* the first image it will send.
|
||||
* 3) API connection receives protobuf CameraImageRequest and calls request_image.
|
||||
* 3.a) API connection receives protobuf CameraImageRequest and calls start_stream.
|
||||
* 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners.
|
||||
|
||||
@@ -14,7 +14,7 @@ static const char *const TAG = "captive_portal";
|
||||
void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
|
||||
AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json"));
|
||||
stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate"));
|
||||
char mac_s[18];
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
const char *mac_str = get_mac_address_pretty_into_buffer(mac_s);
|
||||
#ifdef USE_ESP8266
|
||||
stream->print(ESPHOME_F("{\"mac\":\""));
|
||||
|
||||
@@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
CONF_IAQ = "iaq"
|
||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||
CONF_LABEL = "label"
|
||||
CONF_LIBRETINY = "libretiny"
|
||||
CONF_LOOP = "loop"
|
||||
CONF_NOX_INDEX = "nox_index"
|
||||
@@ -35,6 +36,7 @@ CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
|
||||
CONF_STOP_BITS = "stop_bits"
|
||||
CONF_TARGET_COUNT = "target_count"
|
||||
|
||||
@@ -12,6 +12,7 @@ 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")
|
||||
@@ -109,6 +110,7 @@ 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:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <esp_sleep.h>
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
@@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
const char *reset_reason = get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE>(reset_buffer));
|
||||
const char *wakeup_cause = get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE>(wakeup_buffer));
|
||||
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#include "debug_component.h"
|
||||
#ifdef USE_RP2
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <Arduino.h>
|
||||
#include <hardware/clocks.h>
|
||||
#include <hardware/watchdog.h>
|
||||
#if defined(PICO_RP2350)
|
||||
#include <hardware/structs/powman.h>
|
||||
@@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
|
||||
|
||||
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) { return ""; }
|
||||
|
||||
uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); }
|
||||
// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here.
|
||||
uint32_t DebugComponent::get_free_heap_() { return RAMAllocator<uint8_t>().get_free_heap_size(); }
|
||||
|
||||
size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE> buffer, size_t pos) {
|
||||
constexpr size_t size = DEVICE_INFO_BUFFER_SIZE;
|
||||
char *buf = buffer.data();
|
||||
|
||||
uint32_t cpu_freq = RP2040::f_cpu();
|
||||
uint32_t cpu_freq = clock_get_hz(clk_sys);
|
||||
ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq);
|
||||
pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LABEL
|
||||
from esphome.components.number import Number
|
||||
from esphome.components.select import Select
|
||||
from esphome.components.switch import Switch
|
||||
@@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base")
|
||||
|
||||
CONF_ROTARY = "rotary"
|
||||
CONF_JOYSTICK = "joystick"
|
||||
CONF_LABEL = "label"
|
||||
CONF_MENU = "menu"
|
||||
CONF_BACK = "back"
|
||||
CONF_SELECT = "select"
|
||||
|
||||
@@ -570,6 +570,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
@@ -3280,17 +3283,45 @@ def copy_files():
|
||||
__version__,
|
||||
)
|
||||
|
||||
# Remote extra build files are fetched into the shared download cache in
|
||||
# one parallel batch (conditional requests skip unchanged files), then
|
||||
# copied into the build tree like their local counterparts.
|
||||
sources: dict[str, Path] = {}
|
||||
remote: list[tuple[str, str]] = []
|
||||
for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values():
|
||||
name: str = file[KEY_NAME]
|
||||
path: Path = file[KEY_PATH]
|
||||
if str(path).startswith("http"):
|
||||
import requests
|
||||
|
||||
CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
|
||||
content = requests.get(path, timeout=30).content
|
||||
CORE.relative_build_path(name).write_bytes(content)
|
||||
remote.append((name, str(path)))
|
||||
else:
|
||||
copy_file_if_changed(path, CORE.relative_build_path(name))
|
||||
sources[name] = path
|
||||
if remote:
|
||||
# Imported lazily: requests (via external_files) is a heavy import
|
||||
# and remote extra build files are rare.
|
||||
from esphome import external_files
|
||||
|
||||
downloads: list[external_files.RemoteFile] = []
|
||||
for name, url in remote:
|
||||
cache_path = external_files.compute_local_file_path(KEY_ESP32, url)
|
||||
# Unverifiable bytes: an unrevalidated copy is an error, matching
|
||||
# the old always-download behavior on network failure.
|
||||
downloads.append(
|
||||
external_files.RemoteFile(url, cache_path, allow_stale=False)
|
||||
)
|
||||
sources[name] = cache_path
|
||||
try:
|
||||
external_files.download_content_many(
|
||||
downloads, description="extra build file(s)"
|
||||
)
|
||||
except cv.MultipleInvalid as e:
|
||||
details = "; ".join(str(err) for err in e.errors)
|
||||
raise EsphomeError(
|
||||
f"Could not download extra build file(s): {details}"
|
||||
) from e
|
||||
except cv.Invalid as e:
|
||||
raise EsphomeError(f"Could not download extra build file(s): {e}") from e
|
||||
for name, source in sources.items():
|
||||
copy_file_if_changed(source, CORE.relative_build_path(name))
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
|
||||
@@ -360,17 +360,6 @@ static bool has_fault_addr() {
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
|
||||
}
|
||||
|
||||
// Append both cores' backtrace addresses to buf; returns the new position.
|
||||
static int append_all_backtraces(char *buf, int size, int pos) {
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
|
||||
s_raw_crash_data.other_reg_frame_count);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
|
||||
// The record was captured by a different firmware build (it survives soft
|
||||
// resets, including the OTA reboot), so symbolizing its addresses against the
|
||||
// current ELF would produce misleading symbols. Print them with lowercase
|
||||
@@ -443,11 +432,23 @@ void crash_handler_log() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Build addr2line hint with all captured addresses for easy copy-paste
|
||||
// Build addr2line hints for easy copy-paste. One line per core: the two
|
||||
// backtraces are separate stacks, and a combined list decodes as one
|
||||
// impossible call chain (and can overflow the buffer, dropping addresses).
|
||||
static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf";
|
||||
char hint[256];
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
|
||||
append_all_backtraces(hint, sizeof(hint), pos);
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc);
|
||||
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
|
||||
s_raw_crash_data.reg_frame_count);
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
#if SOC_CPU_CORES_NUM > 1
|
||||
if (s_raw_crash_data.other_backtrace_count > 0) {
|
||||
pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD);
|
||||
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
|
||||
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::esp32
|
||||
|
||||
@@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); }
|
||||
|
||||
bool has_custom_mac_address() {
|
||||
#if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC)
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
// do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails
|
||||
#ifndef USE_ESP32_VARIANT_ESP32
|
||||
return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) &&
|
||||
|
||||
@@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
|
||||
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
|
||||
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
|
||||
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
|
||||
case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init
|
||||
case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init
|
||||
return;
|
||||
|
||||
default:
|
||||
@@ -674,21 +676,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[6]) const {
|
||||
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) 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, 6);
|
||||
memcpy(out, mac, MAC_ADDRESS_SIZE);
|
||||
} else {
|
||||
memset(out, 0, 6);
|
||||
memset(out, 0, MAC_ADDRESS_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
|
||||
|
||||
void ESP32BLE::dump_config() {
|
||||
uint8_t mac_address[6];
|
||||
uint8_t mac_address[MAC_ADDRESS_SIZE];
|
||||
this->get_mac_msb_first(mac_address);
|
||||
if (mac_address_is_valid(mac_address)) {
|
||||
const char *io_capability_s;
|
||||
@@ -713,7 +715,7 @@ void ESP32BLE::dump_config() {
|
||||
break;
|
||||
}
|
||||
|
||||
char mac_s[18];
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
format_mac_addr_upper(mac_address, mac_s);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE:\n"
|
||||
|
||||
@@ -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[6]) const;
|
||||
void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
|
||||
float get_setup_priority() const override;
|
||||
void set_name(const char *name) { this->name_ = name; }
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
@@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
idf_version,
|
||||
request_bluetooth,
|
||||
request_software_coexistence,
|
||||
)
|
||||
@@ -35,10 +38,12 @@ from esphome.const import (
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TRIGGER_ID,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "esp32_ble_tracker"
|
||||
|
||||
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
CODEOWNERS = ["@bdraco"]
|
||||
@@ -125,11 +130,70 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far
|
||||
# longer than the configured window (espressif/esp-idf#18931). Before the fix,
|
||||
# the default 30 ms window in a 320 ms interval effectively scanned at a much
|
||||
# higher duty cycle than requested; with the fix, that same default only
|
||||
# listens 9.4 % of the time and misses most advertisements when wifi shares
|
||||
# the radio. Espressif recommends setting the window equal to the interval in
|
||||
# that case: the coexistence arbiter still shares the radio with wifi, and
|
||||
# BLE uses the airtime wifi does not claim.
|
||||
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerData:
|
||||
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
|
||||
|
||||
scan_window_defaulted: bool = False
|
||||
|
||||
|
||||
def _get_data() -> TrackerData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = TrackerData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def _scan_window_default() -> TimePeriod:
|
||||
"""Schema default for the scan window.
|
||||
|
||||
Records that the user did not set a window, so _raise_defaulted_scan_window
|
||||
can tell a defaulted 30 ms from an explicit one; the raise itself must wait
|
||||
for the outer schema because it depends on software_coexistence, a sibling
|
||||
key not yet resolved here.
|
||||
"""
|
||||
_get_data().scan_window_defaulted = True
|
||||
return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW)
|
||||
|
||||
|
||||
def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
|
||||
"""Raise a defaulted scan window to the interval where that is safe.
|
||||
|
||||
Only when the coexistence arbiter is compiled in (software_coexistence,
|
||||
present iff wifi is configured and not disabled by the user) and the IDF
|
||||
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
|
||||
scan would starve wifi outright, and a user-set window is never touched.
|
||||
Raising to the interval cannot invalidate the already-validated
|
||||
parameters, so no re-validation is needed.
|
||||
"""
|
||||
if (
|
||||
_get_data().scan_window_defaulted
|
||||
and config.get(CONF_SOFTWARE_COEXISTENCE)
|
||||
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
|
||||
):
|
||||
params = config[CONF_SCAN_PARAMETERS]
|
||||
# Copy so the config dump shows a plain value instead of a YAML
|
||||
# anchor/alias pair pointing at the interval.
|
||||
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
|
||||
return config
|
||||
|
||||
|
||||
# 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.
|
||||
# The window default is conditional (see _scan_window_default above).
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
|
||||
"320ms", supports_active=True
|
||||
"320ms", window_default=_scan_window_default
|
||||
)
|
||||
|
||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||
@@ -185,6 +249,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
validate_max_connections_deprecated,
|
||||
_raise_defaulted_scan_window,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ void ESP32BLETracker::setup() {
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
|
||||
if (state == ota::OTA_STARTED) {
|
||||
ESP_LOGD(TAG, "Stopping scan for OTA");
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->stop_scan();
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
@@ -190,7 +191,9 @@ void ESP32BLETracker::loop() {
|
||||
void ESP32BLETracker::start_scan() { this->start_scan_(true); }
|
||||
|
||||
void ESP32BLETracker::stop_scan() {
|
||||
ESP_LOGD(TAG, "Stopping scan.");
|
||||
// V to match the start log: the mode-switch and OTA callers narrate their
|
||||
// reason at D themselves, and the user-facing stop action is deliberate.
|
||||
ESP_LOGV(TAG, "Stopping scan.");
|
||||
this->scan_continuous_ = false;
|
||||
this->stop_scan_();
|
||||
}
|
||||
@@ -199,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_();
|
||||
|
||||
void ESP32BLETracker::stop_scan_() {
|
||||
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
|
||||
// If scanner is already idle, there's nothing to stop - this is not an error
|
||||
if (this->scanner_state_ != ScannerState::IDLE) {
|
||||
// IDLE means there is nothing to stop; STOPPING means a stop is already in
|
||||
// flight and will finish on its own. Neither is an error.
|
||||
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
|
||||
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -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[6]) { this->parent_->get_mac_msb_first(out); }
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { 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);
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components import esp32
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -16,8 +16,10 @@ from esphome.const import (
|
||||
CONF_VARIANT,
|
||||
)
|
||||
from esphome.cpp_generator import add_define
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
# esp32_ble raises the task watchdog around the remote BT controller bring-up
|
||||
AUTO_LOAD = ["watchdog"]
|
||||
|
||||
@@ -33,7 +35,6 @@ CONF_DATA_READY_PIN = "data_ready_pin"
|
||||
CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high"
|
||||
CONF_HANDSHAKE_PIN = "handshake_pin"
|
||||
CONF_SDIO_FREQUENCY = "sdio_frequency"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_SPI_MODE = "spi_mode"
|
||||
|
||||
# Shared fields for both transport modes
|
||||
@@ -125,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# The esp_hosted releases compatible with older ESP-IDF versions crash at
|
||||
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
|
||||
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
|
||||
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
|
||||
raise cv.Invalid(
|
||||
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
|
||||
"Remove the framework version from your configuration to use the "
|
||||
"recommended version, or pin a version at or above 5.3."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _configure_sdio(config):
|
||||
slot = config[CONF_SLOT]
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
@@ -252,18 +269,14 @@ async def to_code(config):
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions
|
||||
# Library versions; this component set requires ESP-IDF 5.3 or newer,
|
||||
# which is enforced at validation time.
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
if idf_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11")
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
|
||||
esp32.add_extra_script(
|
||||
"post",
|
||||
"esp32_hosted.py",
|
||||
|
||||
@@ -113,6 +113,9 @@ def get_download_types(storage_json):
|
||||
the shape stable so the download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
# No recorded firmware path means nothing was built; no downloads.
|
||||
if storage_json.firmware_bin_path is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"title": "Standard format",
|
||||
|
||||
@@ -132,6 +132,7 @@ 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
|
||||
@@ -153,6 +154,7 @@ _PHY_TYPE_TO_DEFINE = {
|
||||
"W6300": "USE_ETHERNET_W6300",
|
||||
"GENERIC": "USE_ETHERNET_GENERIC",
|
||||
"YT8531": "USE_ETHERNET_YT8531",
|
||||
"CH390": "USE_ETHERNET_CH390",
|
||||
}
|
||||
|
||||
|
||||
@@ -176,13 +178,14 @@ _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"}
|
||||
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"}
|
||||
|
||||
# ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver)
|
||||
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"}
|
||||
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"}
|
||||
# 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.
|
||||
@@ -352,7 +355,7 @@ def _validate(config):
|
||||
" clk:\n"
|
||||
" mode: %s\n"
|
||||
" pin: %s\n"
|
||||
"Removal scheduled for 2026.9.0.",
|
||||
"Removal scheduled for 2026.11.0.",
|
||||
config[CONF_CLK_MODE],
|
||||
mode,
|
||||
pin,
|
||||
@@ -480,6 +483,12 @@ 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(
|
||||
{
|
||||
@@ -494,6 +503,7 @@ 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])),
|
||||
@@ -629,8 +639,11 @@ 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
|
||||
# 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":
|
||||
# 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
|
||||
):
|
||||
add_idf_sdkconfig_option(
|
||||
f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ enum EthernetType : uint8_t {
|
||||
ETHERNET_TYPE_W6300,
|
||||
ETHERNET_TYPE_GENERIC,
|
||||
ETHERNET_TYPE_YT8531,
|
||||
ETHERNET_TYPE_CH390,
|
||||
};
|
||||
|
||||
struct ManualIP {
|
||||
@@ -139,11 +140,17 @@ class EthernetComponent final : public Component {
|
||||
bool is_disabled() { return this->disabled_; }
|
||||
bool is_enabled() { return !this->disabled_; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// esp_netif handle, used by network for default-route arbitration.
|
||||
/// nullptr until the driver/netif installation has run.
|
||||
esp_netif_t *get_esp_netif() { return this->eth_netif_; }
|
||||
#endif
|
||||
|
||||
void set_type(EthernetType type);
|
||||
#ifdef USE_ETHERNET_MANUAL_IP
|
||||
void set_manual_ip(const ManualIP &manual_ip);
|
||||
#endif
|
||||
void set_fixed_mac(const std::array<uint8_t, 6> &mac) { this->fixed_mac_ = mac; }
|
||||
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
|
||||
|
||||
network::IPAddresses get_ip_addresses();
|
||||
network::IPAddress get_dns_address(uint8_t num);
|
||||
@@ -335,7 +342,7 @@ class EthernetComponent final : public Component {
|
||||
bool ipv6_setup_done_{false};
|
||||
#endif /* LWIP_IPV6 */
|
||||
|
||||
optional<std::array<uint8_t, 6>> fixed_mac_;
|
||||
optional<std::array<uint8_t, MAC_ADDRESS_SIZE>> fixed_mac_;
|
||||
|
||||
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
|
||||
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
#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>
|
||||
@@ -215,6 +221,8 @@ 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)
|
||||
@@ -236,6 +244,11 @@ 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_;
|
||||
@@ -360,6 +373,12 @@ 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: {
|
||||
@@ -410,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
#endif // !USE_ETHERNET_SPI
|
||||
|
||||
// use ESP internal eth mac
|
||||
uint8_t mac_addr[6];
|
||||
uint8_t mac_addr[MAC_ADDRESS_SIZE];
|
||||
if (this->fixed_mac_.has_value()) {
|
||||
memcpy(mac_addr, this->fixed_mac_->data(), 6);
|
||||
memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE);
|
||||
} else {
|
||||
esp_read_mac(mac_addr, ESP_MAC_ETH);
|
||||
}
|
||||
@@ -519,6 +538,10 @@ 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:
|
||||
@@ -766,16 +789,25 @@ void EthernetComponent::start_connect_() {
|
||||
|
||||
#ifdef USE_ETHERNET_MANUAL_IP
|
||||
if (this->manual_ip_.has_value()) {
|
||||
LwIPLock lock;
|
||||
// Set DNS through esp_netif so the servers are stored in the netif's own
|
||||
// dns[] array; raw dns_setserver() would be lost when the default-route
|
||||
// arbitration re-applies the default netif's DNS.
|
||||
// Log-only on failure: the link still has a working IP/gateway, so degraded
|
||||
// name resolution does not justify marking the whole component failed.
|
||||
esp_netif_dns_info_t dns{};
|
||||
if (this->manual_ip_->dns1.is_set()) {
|
||||
ip_addr_t d;
|
||||
d = this->manual_ip_->dns1;
|
||||
dns_setserver(0, &d);
|
||||
dns.ip = this->manual_ip_->dns1;
|
||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
if (this->manual_ip_->dns2.is_set()) {
|
||||
ip_addr_t d;
|
||||
d = this->manual_ip_->dns2;
|
||||
dns_setserver(1, &d);
|
||||
dns.ip = this->manual_ip_->dns2;
|
||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
@@ -903,7 +935,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
|
||||
// of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC.
|
||||
if (this->fixed_mac_.has_value()) {
|
||||
memcpy(mac, this->fixed_mac_->data(), 6);
|
||||
memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE);
|
||||
} else {
|
||||
esp_read_mac(mac, ESP_MAC_ETH);
|
||||
}
|
||||
@@ -921,7 +953,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_eth_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, buf.data());
|
||||
return buf.data();
|
||||
|
||||
@@ -245,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (this->eth_ != nullptr) {
|
||||
this->eth_->macAddress(mac);
|
||||
} else {
|
||||
memset(mac, 0, 6);
|
||||
memset(mac, 0, MAC_ADDRESS_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_eth_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, buf.data());
|
||||
return buf.data();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -43,15 +42,13 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# If the MDI file cannot be downloaded within this time, abort.
|
||||
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
|
||||
|
||||
SOURCE_LOCAL = "local"
|
||||
SOURCE_WEB = "web"
|
||||
|
||||
@@ -65,16 +62,16 @@ MDI_SOURCES = {
|
||||
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
|
||||
}
|
||||
|
||||
# Shared by the schema validator and the prefetch extractor so they cannot
|
||||
# drift.
|
||||
_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
|
||||
|
||||
def compute_local_image_path(value) -> Path:
|
||||
|
||||
def compute_local_image_path(value: str | ConfigType) -> Path:
|
||||
url = value[CONF_URL] if isinstance(value, dict) else value
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
# Downloaded files are cached under the shared `image` domain directory so
|
||||
# the cache location is unaffected by which platform requested the file.
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def local_path(value):
|
||||
@@ -83,16 +80,20 @@ def local_path(value):
|
||||
|
||||
|
||||
def download_file(url, path):
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
|
||||
# silently ignored on a per-run memo hit anyway (memos key by path).
|
||||
external_files.download_content(url, path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def download_gh_svg(value, source):
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
|
||||
path = base_dir / f"{mdi_id}.svg"
|
||||
return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg"
|
||||
|
||||
url = MDI_SOURCES[source] + mdi_id + ".svg"
|
||||
|
||||
def download_gh_svg(value: str | ConfigType, source: str) -> str:
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
url, path = _gh_svg_url_path(mdi_id, source)
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
@@ -101,17 +102,53 @@ def download_image(value):
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
def _parse_remote_shorthand(value: str) -> RemoteFile | None:
|
||||
"""Parse a string `file:` shorthand to its remote file; None if local.
|
||||
|
||||
Raises cv.Invalid for a malformed icon name. Shared by the schema
|
||||
validator and the prefetch extractor so they cannot drift.
|
||||
"""
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) == 2 and parts[0] in MDI_SOURCES:
|
||||
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
|
||||
if match is None:
|
||||
if _MDI_ICON_RE.match(parts[1]) is None:
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
return RemoteFile(*_gh_svg_url_path(parts[1], parts[0]))
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
return RemoteFile(value, compute_local_image_path(value))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_file_ref(value: object) -> RemoteFile | None:
|
||||
"""Map a raw, pre-schema `file:` value to its remote file.
|
||||
|
||||
Returns None for local files and anything it does not recognize; the
|
||||
schema validators stay authoritative.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return _parse_remote_shorthand(value)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
source = value.get(CONF_SOURCE)
|
||||
if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str):
|
||||
return RemoteFile(url, compute_local_image_path(url))
|
||||
if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str):
|
||||
return RemoteFile(*_gh_svg_url_path(icon, source))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
return _extract_file_ref(entry.get(CONF_FILE))
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
if (remote := _parse_remote_shorthand(value)) is not None:
|
||||
return download_file(remote.url, remote.path)
|
||||
|
||||
value = cv.file_(value)
|
||||
return local_path(value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections.abc import MutableMapping
|
||||
from collections.abc import Iterable, MutableMapping
|
||||
import functools
|
||||
import hashlib
|
||||
from itertools import accumulate
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -17,7 +16,6 @@ from freetype import (
|
||||
FT_Exception,
|
||||
ft_pixel_mode_mono,
|
||||
)
|
||||
import requests
|
||||
|
||||
from esphome import external_files
|
||||
import esphome.codegen as cg
|
||||
@@ -36,6 +34,7 @@ from esphome.const import (
|
||||
CONF_WEIGHT,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -295,45 +294,80 @@ def validate_weight_name(value):
|
||||
return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)]
|
||||
|
||||
|
||||
def _compute_local_font_path(value: dict) -> Path:
|
||||
url = value[CONF_URL]
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
_LOGGER.debug("_compute_local_font_path: %s", base_dir / key)
|
||||
return base_dir / key
|
||||
def _web_font_path(value: dict) -> Path:
|
||||
return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf"
|
||||
|
||||
|
||||
def download_gfont(value):
|
||||
def _gfonts_css_url(value: dict) -> str:
|
||||
return (
|
||||
f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}"
|
||||
f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
|
||||
)
|
||||
|
||||
|
||||
def _gfonts_cache_path(value: dict, suffix: str) -> Path:
|
||||
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
|
||||
return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}"
|
||||
|
||||
|
||||
def _gfonts_ttf_path(value: dict) -> Path:
|
||||
return _gfonts_cache_path(value, "ttf")
|
||||
|
||||
|
||||
def _gfonts_css_path(value: dict) -> Path:
|
||||
return _gfonts_cache_path(value, "css")
|
||||
|
||||
|
||||
def _parse_gfonts_css(css: str) -> str | None:
|
||||
"""Extract the truetype URL from a Google Fonts CSS response."""
|
||||
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def download_gfont(value: ConfigType) -> ConfigType:
|
||||
if value in FONT_CACHE:
|
||||
return value
|
||||
name = (
|
||||
f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
|
||||
)
|
||||
url = f"https://fonts.googleapis.com/css2?family={name}"
|
||||
path = (
|
||||
external_files.compute_local_file_dir(DOMAIN)
|
||||
/ f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
|
||||
)
|
||||
path = _gfonts_ttf_path(value)
|
||||
if not external_files.is_file_recent(path, value[CONF_REFRESH]):
|
||||
_LOGGER.debug("download_gfont: path=%s", path)
|
||||
url = _gfonts_css_url(value)
|
||||
css_path = _gfonts_css_path(value)
|
||||
try:
|
||||
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
css_bytes = external_files.download_content(url, css_path)
|
||||
except cv.Invalid as e:
|
||||
raise cv.Invalid(
|
||||
f"Could not download font at {url}, please check the fonts exists "
|
||||
f"at google fonts ({e})"
|
||||
) from e
|
||||
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
|
||||
if match is None:
|
||||
if not (
|
||||
external_files.is_fresh_this_run(css_path) or CORE.skip_external_update
|
||||
):
|
||||
# Same rule as PREFETCH_FILES stage two: a CSS body that could
|
||||
# not be revalidated may name a rotated ttf URL. Use the cached
|
||||
# font instead (the failed check already warned).
|
||||
if path.exists():
|
||||
FONT_CACHE[value] = path
|
||||
return value
|
||||
raise cv.Invalid(
|
||||
f"Could not extract ttf file from gfonts response for {name}, "
|
||||
f"please report this."
|
||||
f"Could not refresh the Google Fonts CSS for "
|
||||
f"{value[CONF_FAMILY]} and no cached font is available"
|
||||
)
|
||||
try:
|
||||
css = css_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
# Do not leave an unusable body in the cache to be served again.
|
||||
css_path.unlink(missing_ok=True)
|
||||
raise cv.Invalid(
|
||||
f"Bad response from Google Fonts for {value[CONF_FAMILY]}: "
|
||||
f"not a text document"
|
||||
) from e
|
||||
ttf_url = _parse_gfonts_css(css)
|
||||
if ttf_url is None:
|
||||
css_path.unlink(missing_ok=True)
|
||||
raise cv.Invalid(
|
||||
f"Could not extract ttf file from gfonts response for "
|
||||
f"{value[CONF_FAMILY]}, please report this."
|
||||
)
|
||||
|
||||
ttf_url = match.group(1)
|
||||
_LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
|
||||
|
||||
external_files.download_content(ttf_url, path)
|
||||
@@ -344,11 +378,11 @@ def download_gfont(value):
|
||||
return value
|
||||
|
||||
|
||||
def download_web_font(value):
|
||||
def download_web_font(value: ConfigType) -> ConfigType:
|
||||
if value in FONT_CACHE:
|
||||
return value
|
||||
url = value[CONF_URL]
|
||||
path = _compute_local_font_path(value) / "font.ttf"
|
||||
path = _web_font_path(value)
|
||||
|
||||
external_files.download_content(url, path)
|
||||
_LOGGER.debug("download_web_font: path=%s", path)
|
||||
@@ -356,13 +390,18 @@ def download_web_font(value):
|
||||
return value
|
||||
|
||||
|
||||
# Shared by the schema and the prefetch extractor so they cannot drift.
|
||||
_DEFAULT_WEIGHT = "regular"
|
||||
_DEFAULT_ITALIC = False
|
||||
_DEFAULT_REFRESH = "1d"
|
||||
_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name)
|
||||
_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh)
|
||||
|
||||
EXTERNAL_FONT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WEIGHT, default="regular"): cv.Any(
|
||||
cv.int_, validate_weight_name
|
||||
),
|
||||
cv.Optional(CONF_ITALIC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh),
|
||||
cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR,
|
||||
cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean,
|
||||
cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -385,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$")
|
||||
|
||||
|
||||
def _shorthand_to_file_dict(value: str) -> ConfigType | None:
|
||||
"""Typed-dict form of a remote font shorthand.
|
||||
|
||||
Shared by the schema validator and the prefetch extractor so the two
|
||||
cannot drift. Returns None for values that are not remote shorthand
|
||||
(i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand.
|
||||
"""
|
||||
if value.startswith("gfonts://"):
|
||||
match = re.match(r"^gfonts://([^@]+)(@.+)?$", value)
|
||||
if match is None:
|
||||
if (match := _GFONTS_SHORTHAND_RE.match(value)) is None:
|
||||
raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it")
|
||||
family = match.group(1)
|
||||
weight = match.group(2)
|
||||
data = {
|
||||
data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)}
|
||||
if match.group(2):
|
||||
data[CONF_WEIGHT] = match.group(2)[1:]
|
||||
return data
|
||||
if value.startswith(("http://", "https://")):
|
||||
return {CONF_TYPE: TYPE_WEB, CONF_URL: value}
|
||||
return None
|
||||
|
||||
|
||||
def _extract_remote_font(value: object) -> ConfigType | None:
|
||||
"""Map a raw, pre-schema font `file:` value to a normalized remote spec.
|
||||
|
||||
Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for
|
||||
the prefetch hooks; returns None for local fonts and anything it does
|
||||
not recognize. A wrong answer only wastes or misses a prefetch, the
|
||||
schema validators stay authoritative.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = _shorthand_to_file_dict(value)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
font_type = value.get(CONF_TYPE)
|
||||
if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str):
|
||||
return {CONF_TYPE: TYPE_WEB, CONF_URL: url}
|
||||
if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str):
|
||||
try:
|
||||
italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC))
|
||||
weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT))
|
||||
refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH))
|
||||
except cv.Invalid:
|
||||
return None
|
||||
return {
|
||||
CONF_TYPE: TYPE_GFONTS,
|
||||
CONF_FAMILY: family,
|
||||
CONF_WEIGHT: weight,
|
||||
CONF_ITALIC: italic,
|
||||
CONF_REFRESH: refresh,
|
||||
}
|
||||
if weight is not None:
|
||||
data[CONF_WEIGHT] = weight[1:]
|
||||
return font_file_schema(data)
|
||||
return None
|
||||
|
||||
if value.startswith(("http://", "https://")):
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
CONF_URL: value,
|
||||
}
|
||||
)
|
||||
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_LOCAL,
|
||||
CONF_PATH: value,
|
||||
}
|
||||
)
|
||||
def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]:
|
||||
"""Yield the remote spec of every `file:` value, including extras."""
|
||||
for entry in entries:
|
||||
values = [entry.get(CONF_FILE)]
|
||||
extras = entry.get(CONF_EXTRAS)
|
||||
if isinstance(extras, dict):
|
||||
# The schema runs cv.ensure_list on extras, so a bare mapping
|
||||
# is valid raw config; mirror that normalization here.
|
||||
extras = [extras]
|
||||
if isinstance(extras, list):
|
||||
values.extend(
|
||||
extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict)
|
||||
)
|
||||
for value in values:
|
||||
if (spec := _extract_remote_font(value)) is not None:
|
||||
yield spec
|
||||
|
||||
|
||||
def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]:
|
||||
"""Batch-download hook: web fonts, then Google Fonts CSS, then ttf.
|
||||
|
||||
Stage one fetches web fonts and the CSS of stale gfonts; stage two
|
||||
parses the now-cached CSS for the ttf URLs it names.
|
||||
"""
|
||||
stage1: list[RemoteFile] = []
|
||||
# Keyed by cache path: the same font at several sizes is one download,
|
||||
# one freshness stat, and one stage-two CSS parse.
|
||||
stale_gfonts: dict[Path, ConfigType] = {}
|
||||
seen_web: set[Path] = set()
|
||||
for spec in _iter_remote_specs(entries):
|
||||
if spec[CONF_TYPE] == TYPE_WEB:
|
||||
if (path := _web_font_path(spec)) not in seen_web:
|
||||
seen_web.add(path)
|
||||
stage1.append(RemoteFile(spec[CONF_URL], path))
|
||||
elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and (
|
||||
not external_files.is_file_recent(
|
||||
_gfonts_ttf_path(spec), spec[CONF_REFRESH]
|
||||
)
|
||||
):
|
||||
stale_gfonts[css_path] = spec
|
||||
stage1.append(RemoteFile(_gfonts_css_url(spec), css_path))
|
||||
yield stage1
|
||||
|
||||
yield [
|
||||
RemoteFile(ttf_url, _gfonts_ttf_path(spec))
|
||||
for css_path, spec in stale_gfonts.items()
|
||||
# Only trust CSS that stage one actually refreshed this run; a
|
||||
# leftover from an earlier run may name a rotated ttf URL.
|
||||
if external_files.is_fresh_this_run(css_path)
|
||||
and css_path.exists()
|
||||
and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace")))
|
||||
is not None
|
||||
]
|
||||
|
||||
|
||||
def validate_file_shorthand(value: object) -> ConfigType:
|
||||
value = cv.string_strict(value)
|
||||
if (data := _shorthand_to_file_dict(value)) is None:
|
||||
data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value}
|
||||
return font_file_schema(data)
|
||||
|
||||
|
||||
TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
|
||||
@@ -29,6 +29,8 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["touchscreen"]
|
||||
@@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None:
|
||||
|
||||
def _cache_path(url: str) -> Path:
|
||||
"""Cache path for a downloaded firmware blob, keyed by URL."""
|
||||
key = hashlib.sha256(url.encode()).hexdigest()[:8]
|
||||
return external_files.compute_local_file_dir(DOMAIN) / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def firmware_path(firmware: dict) -> Path:
|
||||
@@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
firmware = entry.get(CONF_FIRMWARE)
|
||||
if firmware is None:
|
||||
model = str(entry.get(CONF_MODEL, "CUSTOM")).upper()
|
||||
firmware = MODELS.get(model, {}).get(CONF_FIRMWARE)
|
||||
if (
|
||||
isinstance(firmware, dict)
|
||||
and CONF_FILE not in firmware
|
||||
and isinstance(url := firmware.get(CONF_URL), str)
|
||||
):
|
||||
return RemoteFile(url, _cache_path(url))
|
||||
return None
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref)
|
||||
|
||||
|
||||
def _config_schema(config):
|
||||
model_option = {
|
||||
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
@@ -0,0 +1,36 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
CONF_IS_CONNECTED = "is_connected"
|
||||
|
||||
HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
|
||||
cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema(
|
||||
HoermannHcpConnectedBinarySensor,
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_IS_CONNECTED),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if (conf := config.get(CONF_IS_CONNECTED)) is not None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
var = await binary_sensor.new_binary_sensor(conf, parent)
|
||||
await cg.register_component(var, conf)
|
||||
@@ -0,0 +1,18 @@
|
||||
#include "hoermann_hcp_binary_sensor.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.binary_sensor";
|
||||
|
||||
void HoermannHcpConnectedBinarySensor::setup() {
|
||||
// Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive
|
||||
// their timers.
|
||||
this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); });
|
||||
this->publish_initial_state(this->parent_->is_valid());
|
||||
}
|
||||
|
||||
void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); }
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component {
|
||||
public:
|
||||
explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
HoermannHcp *const parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
@@ -0,0 +1,87 @@
|
||||
#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
|
||||
@@ -0,0 +1,27 @@
|
||||
#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
|
||||
@@ -0,0 +1,464 @@
|
||||
#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;
|
||||
// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
|
||||
static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
|
||||
|
||||
// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
|
||||
// rest names the button - the low byte for the door commands, the second register for those that do not fit
|
||||
// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
|
||||
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
|
||||
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
|
||||
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
|
||||
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
|
||||
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
|
||||
|
||||
// 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 ®isters, 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_) {
|
||||
// Dropping after the press was presented leaves the door without its release value, which is worth saying
|
||||
// apart from a command the controller never looked at.
|
||||
if (this->command_written_at_ != 0) {
|
||||
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
|
||||
this->next_command_->name);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
|
||||
}
|
||||
this->drop_command_();
|
||||
// Children may have assumed the command would land, so let them re-derive from the door.
|
||||
this->changed_ = true;
|
||||
}
|
||||
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
|
||||
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
|
||||
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
|
||||
this->clear_target_();
|
||||
}
|
||||
// The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
|
||||
if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
|
||||
this->forget_light_toggles_();
|
||||
}
|
||||
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 ®isters) {
|
||||
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 ®isters) {
|
||||
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]);
|
||||
if (registers.size() > 6) {
|
||||
this->on_light_reg_(registers[6]);
|
||||
return {};
|
||||
}
|
||||
// Nothing refreshes the lamp any more, so what was read before must not be commanded against.
|
||||
this->set_light_seen_(false);
|
||||
if (!this->short_broadcast_logged_) {
|
||||
this->short_broadcast_logged_ = true;
|
||||
ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
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(command->pressed_value_2);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Between the two events there is nothing to report, including in the second register.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
// Enough time passed: present the "key released" values and clear the command.
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
this->next_command_ = nullptr;
|
||||
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
|
||||
// to wait for, so it must not re-arm the watchdog.
|
||||
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
|
||||
this->light_toggle_released_at_ = millis();
|
||||
registers.push_back(command->released_value);
|
||||
registers.push_back(command->released_value_2);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
|
||||
// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
|
||||
void HoermannHcp::on_light_reg_(uint16_t value) {
|
||||
this->set_light_seen_(true);
|
||||
this->set_light_on_((value & 0x0010) != 0);
|
||||
}
|
||||
|
||||
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.
|
||||
if (command.clears_target)
|
||||
this->clear_target_();
|
||||
this->next_command_ = &command;
|
||||
this->command_queued_at_ = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
bool HoermannHcp::toggle_light() {
|
||||
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
|
||||
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
|
||||
return false;
|
||||
}
|
||||
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
|
||||
return false;
|
||||
this->light_toggles_in_flight_++;
|
||||
return true;
|
||||
}
|
||||
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
|
||||
|
||||
uint8_t HoermannHcp::unsent_light_toggles_() const {
|
||||
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
bool HoermannHcp::cancel_light_toggle() {
|
||||
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
|
||||
// command can be withdrawn.
|
||||
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
|
||||
return false;
|
||||
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
|
||||
this->drop_command_();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HoermannHcp::stop_door() {
|
||||
if (!is_moving(this->door_state_)) {
|
||||
this->clear_target_();
|
||||
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_queued_at_ = millis();
|
||||
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
|
||||
// A door already travelling that way is on its way; one moving the other way has to turn around first.
|
||||
this->target_started_ = this->door_state_ == this->target_direction_;
|
||||
return true;
|
||||
}
|
||||
|
||||
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->drop_command_();
|
||||
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
|
||||
this->clear_target_();
|
||||
this->forget_light_toggles_();
|
||||
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
|
||||
this->set_light_seen_(false);
|
||||
this->short_broadcast_logged_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::drop_command_() {
|
||||
const bool was_light_toggle = this->is_light_toggle_pending_();
|
||||
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
if (was_light_toggle) {
|
||||
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
|
||||
this->light_toggle_settled_();
|
||||
} else {
|
||||
this->clear_target_();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::light_toggle_settled_() {
|
||||
if (this->light_toggles_in_flight_ == 0)
|
||||
return;
|
||||
this->light_toggles_in_flight_--;
|
||||
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
|
||||
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// The light was showing where the lamp was heading, so it has to be told to look again.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
void HoermannHcp::forget_light_toggles_() {
|
||||
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
|
||||
this->light_toggle_released_at_ = 0;
|
||||
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
|
||||
const uint8_t unsent = this->unsent_light_toggles_();
|
||||
if (this->light_toggles_in_flight_ == unsent)
|
||||
return;
|
||||
this->light_toggles_in_flight_ = unsent;
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_on_(bool on) {
|
||||
if (this->light_on_ == on)
|
||||
return;
|
||||
this->light_on_ = on;
|
||||
this->changed_ = true;
|
||||
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
|
||||
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
|
||||
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
|
||||
return;
|
||||
}
|
||||
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
|
||||
this->light_toggle_settled_();
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_seen_(bool seen) {
|
||||
if (this->light_seen_ == seen)
|
||||
return;
|
||||
this->light_seen_ = seen;
|
||||
// A resting door changes nothing else, so without this the light would never hear about it.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,147 @@
|
||||
#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. Each half also carries a second register, which only the lamp command uses.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
uint16_t released_value;
|
||||
uint16_t pressed_value_2{0x0000};
|
||||
uint16_t released_value_2{0x0000};
|
||||
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
|
||||
bool clears_target{true};
|
||||
};
|
||||
|
||||
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 ®isters) override;
|
||||
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®isters) 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);
|
||||
bool toggle_light();
|
||||
|
||||
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_; }
|
||||
bool is_light_on() const { return this->light_on_; }
|
||||
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
|
||||
// valid without saying anything about the lamp, so is_light_on() would still be its default.
|
||||
bool is_light_known() const { return this->light_seen_; }
|
||||
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
|
||||
// lamp still reads as its old self, so this is what a request has to be judged against.
|
||||
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
|
||||
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
|
||||
// instead of fighting it. Returns false if there is nothing to cancel.
|
||||
bool cancel_light_toggle();
|
||||
|
||||
protected:
|
||||
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
|
||||
bool is_light_toggle_pending_() const;
|
||||
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
|
||||
uint8_t unsent_light_toggles_() const;
|
||||
void record_response_();
|
||||
// Returns false when the bus controller has not fetched the previous command yet.
|
||||
bool queue_command_(const HoermannHcpCommand &command);
|
||||
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
|
||||
void drop_command_();
|
||||
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
|
||||
void light_toggle_settled_();
|
||||
// Stops expecting the toggles the door has already been shown to reach the lamp.
|
||||
void forget_light_toggles_();
|
||||
// Appends the two key-press registers and advances the pending command's press/release state.
|
||||
void push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
void on_light_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_();
|
||||
void set_light_on_(bool on);
|
||||
void set_light_seen_(bool seen);
|
||||
|
||||
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};
|
||||
// Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline.
|
||||
uint32_t target_queued_at_{0};
|
||||
uint32_t command_written_at_{0};
|
||||
uint32_t last_response_{0};
|
||||
// When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the
|
||||
// wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline.
|
||||
uint32_t light_toggle_released_at_{0};
|
||||
|
||||
// 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};
|
||||
uint8_t light_toggles_in_flight_{0};
|
||||
bool target_started_{false};
|
||||
bool valid_{false};
|
||||
bool changed_{false};
|
||||
bool light_on_{false};
|
||||
bool light_seen_{false};
|
||||
bool short_broadcast_logged_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,24 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
HoermannHcpLight = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpLight", light.LightOutput, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
light.light_schema(HoermannHcpLight, light.LightType.BINARY)
|
||||
.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 light.new_light(config, parent)
|
||||
await cg.register_component(var, config)
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "hoermann_hcp_light.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.light";
|
||||
|
||||
light::LightTraits HoermannHcpLight::get_traits() {
|
||||
auto traits = light::LightTraits();
|
||||
traits.set_supported_color_modes({light::ColorMode::ON_OFF});
|
||||
return traits;
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup() {
|
||||
// Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then.
|
||||
this->status_set_warning(LOG_STR("waiting for the bus controller"));
|
||||
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; }
|
||||
|
||||
void HoermannHcpLight::write_state(light::LightState *state) {
|
||||
bool binary;
|
||||
state->current_values_as_binary(&binary);
|
||||
// A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on,
|
||||
// so it is recognised by the value it carried rather than by the current one.
|
||||
const optional<bool> published = this->published_state_;
|
||||
this->published_state_.reset();
|
||||
// LightState::setup() always performs a call, so the very first write here is the restored state coming back
|
||||
// rather than a request.
|
||||
const bool restored = !this->boot_replay_done_;
|
||||
this->boot_replay_done_ = true;
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (binary == heading_on)
|
||||
return;
|
||||
if (restored) {
|
||||
ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing");
|
||||
} else if (published != binary) {
|
||||
if (!this->parent_->is_light_known()) {
|
||||
// Commanding a lamp that has not been read could switch off one that is already on.
|
||||
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
|
||||
} else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) {
|
||||
// A toggle the controller has not fetched is withdrawn outright rather than fought with a second one.
|
||||
return;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Light command was not accepted by the door");
|
||||
}
|
||||
}
|
||||
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
void HoermannHcpLight::update_from_state_() {
|
||||
if (this->light_state_ == nullptr)
|
||||
return;
|
||||
if (!this->parent_->is_valid()) {
|
||||
this->status_set_warning(LOG_STR("bus controller not responding"));
|
||||
return;
|
||||
}
|
||||
if (!this->parent_->is_light_known()) {
|
||||
// Commands are refused until the door says, so say so rather than looking healthy and doing nothing.
|
||||
this->status_set_warning(LOG_STR("door has not reported the lamp"));
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (this->light_state_->remote_values.is_on() != heading_on)
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours.
|
||||
void HoermannHcpLight::publish_lamp_state_(bool on) {
|
||||
this->published_state_ = on;
|
||||
auto call = this->light_state_->make_call();
|
||||
call.set_state(on);
|
||||
// The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash.
|
||||
call.set_save(false);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpLight : public light::LightOutput, public Component {
|
||||
public:
|
||||
explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void setup_state(light::LightState *state) override;
|
||||
light::LightTraits get_traits() override;
|
||||
void write_state(light::LightState *state) override;
|
||||
|
||||
protected:
|
||||
void update_from_state_();
|
||||
void publish_lamp_state_(bool on);
|
||||
|
||||
HoermannHcp *const parent_;
|
||||
light::LightState *light_state_{nullptr};
|
||||
// Value last published and not yet seen come back, so the write carrying it is that publish, not a request.
|
||||
optional<bool> published_state_;
|
||||
// Set by the first write_state(), which is always the restored state replayed on boot.
|
||||
bool boot_replay_done_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast<std::mutex *>(handle_)->try_lock();
|
||||
void Mutex::unlock() { static_cast<std::mutex *>(handle_)->unlock(); }
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS;
|
||||
static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS;
|
||||
memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address));
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user