mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 01:25:57 +00:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcea8ceb8e | ||
|
|
e67cd5b978 | ||
|
|
06a52f682f | ||
|
|
0fe4d608ce | ||
|
|
7e89d962f4 | ||
|
|
409ff027cc | ||
|
|
cbe6c83e8d | ||
|
|
dab3f370a0 | ||
|
|
4d90dbda97 | ||
|
|
3c7e5000a5 | ||
|
|
7af211ab54 | ||
|
|
d8e90d3ecc | ||
|
|
5420b0106a | ||
|
|
ce082664c9 | ||
|
|
1a30917bb7 | ||
|
|
792441a046 | ||
|
|
964835c523 | ||
|
|
cc81069d02 | ||
|
|
20f63026a2 | ||
|
|
ba567cecc3 | ||
|
|
ee7fb83334 | ||
|
|
9db31db24d | ||
|
|
2724ec3c30 | ||
|
|
30aac5b01c | ||
|
|
ac6d2fe322 | ||
|
|
ceca66c01a | ||
|
|
dc97c5a161 | ||
|
|
97bbcf55d9 | ||
|
|
ef0d2ed139 | ||
|
|
8a88120143 | ||
|
|
49191c1ed8 | ||
|
|
d135ac97e5 | ||
|
|
296dd19361 | ||
|
|
138304cfbe | ||
|
|
2e8700f090 | ||
|
|
5d42a69d7a | ||
|
|
9fef1862be | ||
|
|
80758f11aa | ||
|
|
9d8362738f | ||
|
|
a21d71ddbe | ||
|
|
b2c22b5802 | ||
|
|
f62009ecc9 | ||
|
|
c09e3e0d45 | ||
|
|
cdd7bef74a | ||
|
|
0e1321a813 | ||
|
|
022b612366 | ||
|
|
2dc9fbec5e | ||
|
|
9dafff6a61 | ||
|
|
3c7a1062c8 | ||
|
|
8164cdd018 | ||
|
|
4ea491d46f | ||
|
|
7fdb6cd2d5 | ||
|
|
2d50e07e90 | ||
|
|
78a451a1c0 | ||
|
|
2483621500 | ||
|
|
4f086b6b25 | ||
|
|
c4850a9320 | ||
|
|
91bcb14c09 | ||
|
|
85e4a6cc33 | ||
|
|
149d7cd801 | ||
|
|
ce2e113a6d | ||
|
|
ae5b6d271f | ||
|
|
e1572d7520 | ||
|
|
536afce394 | ||
|
|
152b6a5d50 | ||
|
|
f68ebcf7d1 | ||
|
|
ca5fa6dfaf | ||
|
|
962a82d157 | ||
|
|
c6d4ca7377 | ||
|
|
465a6a3596 | ||
|
|
03c9b001f4 | ||
|
|
d680200e5a | ||
|
|
98dae49440 | ||
|
|
7ec366c1ba | ||
|
|
b1e1b0d7c5 | ||
|
|
ec49eb12dd | ||
|
|
949474efc6 | ||
|
|
708f095b6f | ||
|
|
bf4af703b6 | ||
|
|
541616e19c | ||
|
|
952c3e6584 | ||
|
|
2ea55bee7b | ||
|
|
1931ef32ee | ||
|
|
f2da6c1a56 | ||
|
|
6e9e83bdfd | ||
|
|
fe29a53b71 | ||
|
|
fa2dafcbca | ||
|
|
dc18fced19 | ||
|
|
389d3629c2 | ||
|
|
36a4d87611 | ||
|
|
65db614f22 | ||
|
|
591856e7dc | ||
|
|
b680482092 | ||
|
|
7fe447a4a2 | ||
|
|
8fae4f6767 | ||
|
|
3d458bd9c8 | ||
|
|
fd7510d6e5 | ||
|
|
ffe55f7691 | ||
|
|
40ac344bf2 | ||
|
|
667ffe98d9 | ||
|
|
270f776675 | ||
|
|
965bbd4425 | ||
|
|
e485e5af90 | ||
|
|
c795d0734d | ||
|
|
0e77d47df4 | ||
|
|
47c2f8f31a | ||
|
|
fe53d24e46 | ||
|
|
daf988fde8 | ||
|
|
6604a9cca7 | ||
|
|
ddd45e393b | ||
|
|
0974e8a1f5 | ||
|
|
1478307280 | ||
|
|
fc77cdcb7d | ||
|
|
fcb9da489e | ||
|
|
ab458fffab | ||
|
|
c0c3afe6b1 | ||
|
|
b092f64744 | ||
|
|
6b2938c1f7 | ||
|
|
0e5d902c95 | ||
|
|
11fc8e4c07 | ||
|
|
d9f0f40373 | ||
|
|
e671a602e9 | ||
|
|
7f44b9b826 | ||
|
|
47804f8d08 | ||
|
|
67cc43c8ef | ||
|
|
3e9808162b | ||
|
|
a6f81f3185 | ||
|
|
5296539849 | ||
|
|
de9074fb5e |
No files matched your search
@@ -466,7 +466,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
||||
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Nightly Dev Release
|
||||
|
||||
# Works out the dated dev tag and starts the release workflow with it, so that
|
||||
# the release run is named after the tag it builds. A workflow run name is
|
||||
# fixed when the run starts and cannot read a file or the current date.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: Start release build
|
||||
if: github.repository == 'esphome/esphome'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
actions: write # gh workflow run starts release.yml
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Start the release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not read __version__ from esphome/const.py"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${VERSION}$(date --utc '+%Y%m%d')"
|
||||
echo "Starting release build for ${TAG}"
|
||||
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
|
||||
@@ -1,23 +1,12 @@
|
||||
---
|
||||
name: Publish Release
|
||||
|
||||
# Releases (production and beta) are named after the version they publish.
|
||||
# Dev builds are named after the dated dev tag, which is passed in by the
|
||||
# nightly workflow because a run name cannot compute it itself.
|
||||
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: >-
|
||||
Tag to build. Only supported on dev, where the nightly workflow
|
||||
uses it. Leave empty to build the version from esphome/const.py
|
||||
with today's date appended.
|
||||
required: false
|
||||
default: ""
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
|
||||
@@ -34,8 +23,6 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
# yamllint disable rule:line-length
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" = "release" ]]; then
|
||||
@@ -47,23 +34,12 @@ jobs:
|
||||
ENVIRONMENT="production"
|
||||
fi
|
||||
else
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
# The nightly workflow passes the finished tag so that the run name
|
||||
# matches what is built. Without it, work it out here.
|
||||
TAG="${INPUT_TAG}"
|
||||
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
|
||||
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$TAG" ]]; then
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
fi
|
||||
fi
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
BRANCH_BUILD="true"
|
||||
ENVIRONMENT=""
|
||||
else
|
||||
|
||||
@@ -57,12 +57,6 @@ This document provides essential context for AI models interacting with this pro
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
|
||||
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
|
||||
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
|
||||
these common names (for example the Realtek SDKs used by LibreTiny define
|
||||
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
|
||||
before the compiler sees it, breaking the build and clang-tidy on those platforms.
|
||||
|
||||
* **Python Idioms:**
|
||||
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
|
||||
|
||||
@@ -238,7 +238,6 @@ esphome/components/hlw8032/* @rici4kubicek
|
||||
esphome/components/hm3301/* @freekode
|
||||
esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+9
-11
@@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
@@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
@@ -2001,9 +2001,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
> 1
|
||||
):
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
@@ -2021,7 +2019,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2031,7 +2029,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2039,7 +2037,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
@@ -2047,7 +2045,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
)
|
||||
return 1
|
||||
safe_print(
|
||||
print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
@@ -2056,7 +2054,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
@@ -2082,7 +2080,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
+49
-109
@@ -1,117 +1,26 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
|
||||
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
|
||||
paths, UUIDs and enums store the same string form the YAML dumper
|
||||
produced for them. JSON additionally coerces non-str dict keys to
|
||||
strings; validated configs only use string keys (every schema key
|
||||
validator is ``cv.string``). mtime gates staleness.
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, Lambda
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bump when the on-disk shape changes; a mismatched version falls back
|
||||
# to read_config. The envelope also stamps the writing esphome version:
|
||||
# after an upgrade the cache holds the previous release's validation, so
|
||||
# it falls back once and the re-save self-heals.
|
||||
_CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
Mode 0600 because config validation resolved !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
try:
|
||||
# The legacy YAML cache holds inline-resolved secrets and nothing
|
||||
# reads it anymore; drop it even when the write below fails. A
|
||||
# failed removal leaves resolved secrets on disk, so it warns.
|
||||
try:
|
||||
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not remove the legacy validated-config cache: %s", err
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
|
||||
separators=(",", ":"),
|
||||
default=_json_default,
|
||||
)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except TypeError as err:
|
||||
# Structural, not transient: this config can never cache (e.g. a
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, a different cache
|
||||
version, or the sidecar is incomplete. The loaded config carries no
|
||||
source ranges; callers must not feed it into read_config/write_cpp.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
envelope = json.loads(
|
||||
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
|
||||
)
|
||||
except (OSError, ValueError) as err:
|
||||
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(envelope, dict)
|
||||
or envelope.get("v") != _CACHE_VERSION
|
||||
or envelope.get("esphome") != ESPHOME_VERSION
|
||||
or not isinstance(config := envelope.get("config"), dict)
|
||||
):
|
||||
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
|
||||
# Remove before 2027.8: by then every maintained install has saved the
|
||||
# JSON cache at least once and dropped its legacy YAML file.
|
||||
def _legacy_compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path of the pre-JSON YAML cache; only ever removed."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
@@ -123,21 +32,52 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
|
||||
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
IncludeFile/Extend/Remove have no JSON mirror and would stringify
|
||||
wrong, but none survive validation (config.py's packages merge and
|
||||
the substitution pass consume them) so no guard is spent on them.
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
if isinstance(value, Lambda):
|
||||
return {_LAMBDA_KEY: value.value}
|
||||
return str(value)
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def _decode_object(obj: dict[str, Any]) -> Any:
|
||||
"""Revive the Lambda sentinel; every other mapping passes through."""
|
||||
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
|
||||
return Lambda(value)
|
||||
return obj
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
# Fast path never validates or generates code - no source ranges
|
||||
# needed (see load_yaml). Callers must not feed this config into
|
||||
# read_config/write_cpp: the esp_range consumers in config.py and
|
||||
# cpp_generator.py are isinstance-guarded and would degrade
|
||||
# silently (wrong error/lambda locations) instead of raising.
|
||||
config = yaml_util.load_yaml(
|
||||
cache_path, clear_secrets=False, track_document_range=False
|
||||
)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
@@ -19,7 +19,6 @@ service APIConnection {
|
||||
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -244,12 +243,6 @@ message SerialProxyInfo {
|
||||
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
|
||||
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
|
||||
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
|
||||
//
|
||||
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
|
||||
// have moved to that message as of API 1.15, but are still sent here so that
|
||||
// older clients keep working. Do NOT mark them (deprecated) until the removal
|
||||
// release: in this repo (deprecated) makes the generator drop the field
|
||||
// entirely, so the device would stop sending it.
|
||||
message DeviceInfoResponse {
|
||||
option (id) = 10;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -287,8 +280,6 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.9
|
||||
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
|
||||
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
|
||||
@@ -297,14 +288,11 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.10
|
||||
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
|
||||
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
|
||||
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Supports receiving and saving api encryption key
|
||||
@@ -317,13 +305,10 @@ message DeviceInfoResponse {
|
||||
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// Indicates if Z-Wave proxy support is available and features supported
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
|
||||
// Serial proxy instance metadata
|
||||
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
|
||||
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
|
||||
// Device is unprovisioned and accepts Noise handshakes with the well-known
|
||||
@@ -332,63 +317,6 @@ message DeviceInfoResponse {
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
|
||||
// Asks the device which optional features it supports.
|
||||
//
|
||||
// This message exists so that DeviceInfoResponse does not have to keep growing
|
||||
// a flat list of feature flags. DeviceInfoResponse is served before
|
||||
// authentication, so it is limited to identity information. Capabilities are
|
||||
// only served on an authenticated connection (encrypted as well, when
|
||||
// encryption is configured).
|
||||
//
|
||||
// Clients that see api_version >= 1.15 should read these values from
|
||||
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
|
||||
// Older clients keep reading DeviceInfoResponse, which still carries the same
|
||||
// values, so this is not a breaking change.
|
||||
message DeviceCapabilitiesRequest {
|
||||
option (id) = 149;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
// Empty
|
||||
}
|
||||
|
||||
// Each feature gets its own sub-message so that it can gain fields over time
|
||||
// without crowding the top-level field numbering.
|
||||
//
|
||||
// Note: a sub-message whose fields are all at their default value is not sent
|
||||
// at all, so the presence of a sub-message is not a reliable test for "this
|
||||
// feature is compiled in". Clients should test a value inside it, for example
|
||||
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
|
||||
|
||||
message BluetoothProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
string mac_address = 2 [(max_data_length) = 17, (force) = true];
|
||||
}
|
||||
|
||||
message VoiceAssistantCapabilities {
|
||||
// Bitmask of the features this voice assistant supports
|
||||
uint32 feature_flags = 1;
|
||||
}
|
||||
|
||||
message ZWaveProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
uint32 home_id = 2;
|
||||
}
|
||||
|
||||
message DeviceCapabilitiesResponse {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_SERVER;
|
||||
|
||||
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
repeated SerialProxyInfo serial_proxies = 4
|
||||
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
option (id) = 11;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
@@ -1736,7 +1735,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 15;
|
||||
resp.api_version_minor = 14;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -1850,7 +1849,8 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char bluetooth_mac[18];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
@@ -1904,35 +1904,6 @@ bool APIConnection::send_device_info_response_() {
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
bool APIConnection::send_device_capabilities_response_() {
|
||||
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
|
||||
// below in sync with send_device_info_response_() until those copies are removed.
|
||||
DeviceCapabilitiesResponse resp;
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
|
||||
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
size_t serial_proxy_index = 0;
|
||||
for (auto const &proxy : App.get_serial_proxies()) {
|
||||
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
|
||||
break;
|
||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||
info.name = StringRef(proxy->get_name());
|
||||
info.port_type = proxy->get_port_type();
|
||||
}
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
void APIConnection::on_hello_request(const HelloRequest &msg) {
|
||||
if (!this->send_hello_response_(msg)) {
|
||||
this->on_fatal_error();
|
||||
@@ -1954,11 +1925,6 @@ void APIConnection::on_device_info_request() {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
void APIConnection::on_device_capabilities_request() {
|
||||
if (!this->send_device_capabilities_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
|
||||
|
||||
@@ -266,7 +266,6 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_disconnect_request(const DisconnectRequest &msg);
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_device_capabilities_request();
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() {
|
||||
this->flags_.state_subscription = true;
|
||||
@@ -386,7 +385,6 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
bool send_disconnect_response_();
|
||||
bool send_ping_response_();
|
||||
bool send_device_info_response_();
|
||||
bool send_device_capabilities_response_();
|
||||
#ifdef USE_API_NOISE
|
||||
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
|
||||
@@ -149,7 +149,7 @@ class APIFrameHelper {
|
||||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||||
// and dropped messages.
|
||||
//
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||||
//
|
||||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||||
@@ -312,7 +312,7 @@ class APIFrameHelper {
|
||||
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
|
||||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||||
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
|
||||
@@ -241,82 +241,6 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
|
||||
return pos;
|
||||
}
|
||||
uint32_t BluetoothProxyCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
size += 2 + this->mac_address.size();
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
return pos;
|
||||
}
|
||||
uint32_t VoiceAssistantCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ZWaveProxyCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
size += ProtoSize::calc_uint32(1, this->home_id);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
|
||||
}
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
size += ProtoSize::calc_message_force(1, it.calculate_size());
|
||||
}
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
|
||||
@@ -600,74 +600,6 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothProxyCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
StringRef mac_address{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
class VoiceAssistantCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
class ZWaveProxyCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
uint32_t home_id{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
class DeviceCapabilitiesResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 150;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 102;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
BluetoothProxyCapabilities bluetooth_proxy{};
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
VoiceAssistantCapabilities voice_assistant{};
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
ZWaveProxyCapabilities zwave_proxy{};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
class ListEntitiesDoneResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 19;
|
||||
|
||||
@@ -988,55 +988,6 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
|
||||
this->bluetooth_proxy.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
|
||||
this->voice_assistant.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
|
||||
this->zwave_proxy.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
|
||||
return out.c_str();
|
||||
|
||||
@@ -705,13 +705,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
case 149 /* DeviceCapabilitiesRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
|
||||
#endif
|
||||
this->on_device_capabilities_request();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ class APIServerConnectionBase {
|
||||
void on_ping_response(){};
|
||||
void on_device_info_request(){};
|
||||
|
||||
void on_device_capabilities_request(){};
|
||||
|
||||
void on_list_entities_request(){};
|
||||
|
||||
void on_subscribe_states_request(){};
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Every SDK call the scan reconciler makes. The BDK's own start hardcodes
|
||||
// passive (the active bit is commented out in both stacks), so
|
||||
// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field
|
||||
// the SDK's app_ble_start_scaning() except that prop takes the mode, armed
|
||||
// through the SDK's own operation bookkeeping. The component pins
|
||||
// beken-bdk 3.0.78; the static asserts catch a layout change on a bump.
|
||||
|
||||
#include "bdk_scan.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
|
||||
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
|
||||
|
||||
extern "C" {
|
||||
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
|
||||
// app_ble_actv_state_get, app_ble_env_state_get,
|
||||
// app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX,
|
||||
// bk_ble_* (via ble_api_5_x.h)
|
||||
#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send
|
||||
#if __has_include("gapm_msg.h")
|
||||
#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_*
|
||||
#else
|
||||
#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name
|
||||
#endif
|
||||
}
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble";
|
||||
|
||||
// Pin the SDK surface this file depends on: a beken-bdk bump that moves these
|
||||
// must fail the build, not corrupt the kernel message.
|
||||
static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) &&
|
||||
sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4,
|
||||
"beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() "
|
||||
"against the SDK's app_ble_start_scaning()");
|
||||
static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX,
|
||||
"beken-bdk activity sentinel changed; revalidate the scan reconciler");
|
||||
static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 &&
|
||||
GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5),
|
||||
"beken-bdk GAPM report info changed; revalidate the tracker's demux constants");
|
||||
|
||||
bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; }
|
||||
|
||||
BdkActivityState bdk_scan_state(uint8_t activity_idx) {
|
||||
if (activity_idx == INVALID_ACTIVITY_IDX)
|
||||
return BdkActivityState::IDLE;
|
||||
switch (app_ble_actv_state_get(activity_idx)) {
|
||||
case ACTV_IDLE:
|
||||
return BdkActivityState::IDLE;
|
||||
case ACTV_SCAN_CREATED:
|
||||
return BdkActivityState::CREATED;
|
||||
case ACTV_SCAN_STARTED:
|
||||
return BdkActivityState::STARTED;
|
||||
default:
|
||||
return BdkActivityState::OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t bdk_scan_acquire_activity() {
|
||||
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
|
||||
if (idx == INVALID_ACTIVITY_IDX)
|
||||
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
|
||||
return idx;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_create(uint8_t activity_idx) {
|
||||
ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr);
|
||||
if (ret == ERR_SUCCESS)
|
||||
return BdkOpResult::OK;
|
||||
if (ret == ERR_BLE_STATUS)
|
||||
return BdkOpResult::BUSY;
|
||||
ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast<int>(ret));
|
||||
return BdkOpResult::FAILED;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) {
|
||||
app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr);
|
||||
struct gapm_activity_start_cmd *cmd =
|
||||
KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd);
|
||||
if (cmd == nullptr) {
|
||||
app_ble_reset(); // the SDK's own failure path for an unsent operation
|
||||
ESP_LOGE(TAG, "Scan start failed: kernel message allocation");
|
||||
return BdkOpResult::FAILED;
|
||||
}
|
||||
cmd->operation = GAPM_START_ACTIVITY;
|
||||
cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx;
|
||||
cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER;
|
||||
cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0);
|
||||
cmd->u_param.scan_param.scan_param_1m.scan_intv = interval;
|
||||
cmd->u_param.scan_param.scan_param_1m.scan_wd = window;
|
||||
cmd->u_param.scan_param.scan_param_coded.scan_intv = 0;
|
||||
cmd->u_param.scan_param.scan_param_coded.scan_wd = 0;
|
||||
cmd->u_param.scan_param.dup_filt_pol = 0;
|
||||
cmd->u_param.scan_param.rsvd = 0;
|
||||
cmd->u_param.scan_param.duration = 0; // scan until stopped
|
||||
cmd->u_param.scan_param.period = 10; // matches the SDK's passive start
|
||||
kernel_msg_send(cmd);
|
||||
return BdkOpResult::OK;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
|
||||
ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr);
|
||||
*err_out = static_cast<int>(ret);
|
||||
if (ret == ERR_SUCCESS)
|
||||
return BdkOpResult::OK;
|
||||
// DEBUG on purpose: the reconciler WARNs once per streak and the stuck
|
||||
// ERROR carries this code — a per-retry ERROR would be unbounded.
|
||||
ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast<int>(ret));
|
||||
return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // !CLANG_TIDY && ble_api.h
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -1,51 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
/// Activity index value marking "no scan activity", the BDK's own convention
|
||||
/// (asserted against its symbol in bdk_scan.cpp).
|
||||
inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF;
|
||||
|
||||
/// Scan-relevant controller activity states, read live from the SDK.
|
||||
enum class BdkActivityState : uint8_t {
|
||||
IDLE, ///< No activity (or one whose create failed).
|
||||
CREATED, ///< Created but not started.
|
||||
STARTED, ///< Scanning.
|
||||
OTHER, ///< A non-scan or transitional state; settles on a later read.
|
||||
};
|
||||
|
||||
/// Outcome of a BDK scan operation request.
|
||||
enum class BdkOpResult : uint8_t {
|
||||
OK, ///< Accepted; completion is asynchronous.
|
||||
BUSY, ///< Another controller operation is in flight; retry later.
|
||||
FAILED, ///< Rejected.
|
||||
};
|
||||
|
||||
/// True when no controller operation is in flight (APP_BLE_READY).
|
||||
bool bdk_scan_ready();
|
||||
/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE.
|
||||
BdkActivityState bdk_scan_state(uint8_t activity_idx);
|
||||
/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free.
|
||||
uint8_t bdk_scan_acquire_activity();
|
||||
/// Create the scan activity (asynchronous); started once CREATED is observed.
|
||||
BdkOpResult bdk_scan_create(uint8_t activity_idx);
|
||||
/// Start a created activity: the packed GAPM start, taking the scan mode the
|
||||
/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the
|
||||
/// kernel message could not be allocated (the armed SDK operation is rolled
|
||||
/// back).
|
||||
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active);
|
||||
/// Release the activity: delete when never started (a stop would be
|
||||
/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED
|
||||
/// on any other error; err_out receives the SDK code (0 on success).
|
||||
/// Teardown is asynchronous — observe IDLE to confirm.
|
||||
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out);
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -5,8 +5,7 @@
|
||||
// talks to the Beken BDK BLE stack:
|
||||
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
|
||||
// - the controller BLE address,
|
||||
// - the scan reconciler (request, pacing, bring-up budget) over the
|
||||
// bdk_scan surface,
|
||||
// - the raw controller scan primitives (bk_ble_scan_start/stop),
|
||||
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
|
||||
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
|
||||
// dispatches on the main task and returns reports to the pool — the same
|
||||
@@ -21,13 +20,10 @@
|
||||
|
||||
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
|
||||
|
||||
#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release)
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h" // get_mac_address_raw()
|
||||
#include "esphome/core/log.h"
|
||||
@@ -61,8 +57,9 @@
|
||||
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" {
|
||||
#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t,
|
||||
// BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp)
|
||||
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
|
||||
// app_ble_get_idle_actv_idx_handle, struct scan_param,
|
||||
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
#include "common_bt_defines.h" // struct bd_addr
|
||||
// The controller's public BLE address, populated by the BDK during ble_entry().
|
||||
@@ -79,12 +76,6 @@ namespace esphome::bk72xx_ble {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble";
|
||||
|
||||
static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops
|
||||
static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release
|
||||
static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED
|
||||
static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence
|
||||
static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED)
|
||||
|
||||
// The BDK notice callback is a plain C function pointer with no user argument,
|
||||
// so it reaches the (single) component instance through a file-static pointer.
|
||||
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -104,22 +95,21 @@ static void ble_notice_callback(ble_notice_t notice, void *param) {
|
||||
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
|
||||
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
|
||||
// a signed dBm value packed in a uint8_t).
|
||||
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type,
|
||||
static_cast<uint8_t>(info->evt_type), info->data, info->data_len);
|
||||
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
|
||||
info->data_len);
|
||||
}
|
||||
|
||||
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type,
|
||||
const uint8_t *data, uint16_t data_len) {
|
||||
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint16_t data_len) {
|
||||
BLEScanReport *report = this->report_pool_.allocate();
|
||||
if (report == nullptr) {
|
||||
// Pool exhausted — the queue is full; count and drop.
|
||||
this->report_queue_.increment_dropped_count();
|
||||
return;
|
||||
}
|
||||
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
|
||||
memcpy(report->mac, mac, 6);
|
||||
report->rssi = rssi;
|
||||
report->addr_type = addr_type;
|
||||
report->evt_type = evt_type;
|
||||
report->data_len =
|
||||
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
|
||||
memcpy(report->data, data, report->data_len);
|
||||
@@ -133,9 +123,6 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add
|
||||
|
||||
void BK72xxBLE::setup() {
|
||||
s_ble = this;
|
||||
// The report pool grows lazily on purpose: the BDK notice callback runs in
|
||||
// task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic
|
||||
// stays far below the pool cap, so not warming contains RAM.
|
||||
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
|
||||
// the stack is up (it is re-read once ble_entry() has run).
|
||||
this->resolve_mac_();
|
||||
@@ -186,30 +173,6 @@ void BK72xxBLE::enable() {
|
||||
}
|
||||
|
||||
void BK72xxBLE::loop() {
|
||||
// Keep reconciling toward the requested scan state (e.g. complete a stop
|
||||
// that arrived while a controller operation was in flight), and re-check a
|
||||
// settled scan at low frequency: a controller-side drop re-enters the
|
||||
// bring-up, and the budget's FAILED feeds the tracker's recovery.
|
||||
// Keep driving until settled: any PENDING, plus a terminal stop whose slot
|
||||
// must still be freed. A FAILED scan request is the one combination not
|
||||
// re-driven here — that belongs to the tracker's backoff.
|
||||
const uint32_t pump_now = App.get_loop_component_start_time();
|
||||
if (this->last_result_ == ScanOpResult::PENDING ||
|
||||
(!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) {
|
||||
const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED)
|
||||
? RECONCILE_REJECTED_RETRY_MS
|
||||
: RECONCILE_RETRY_MS;
|
||||
if (pump_now - this->last_advance_ms_ >= gate)
|
||||
this->advance_();
|
||||
} else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED &&
|
||||
pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) {
|
||||
// Re-check a settled scan; scan_start() refills the bring-up budget.
|
||||
// WARN: the only report of a drop that recovers inside its budget.
|
||||
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
|
||||
ScanOpResult::SETTLED)
|
||||
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
|
||||
}
|
||||
|
||||
// Drain the lock-free ring filled by the BLE task; all per-report work runs
|
||||
// here on the main task, then the report returns to the pool.
|
||||
BLEScanReport *report = this->report_queue_.pop();
|
||||
@@ -230,7 +193,7 @@ void BK72xxBLE::loop() {
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
|
||||
}
|
||||
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = this->ble_mac_[i];
|
||||
}
|
||||
@@ -263,7 +226,7 @@ void BK72xxBLE::resolve_mac_() {
|
||||
}
|
||||
}
|
||||
if (nonzero) {
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -275,238 +238,54 @@ void BK72xxBLE::resolve_mac_() {
|
||||
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
|
||||
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
|
||||
// would carry differently.
|
||||
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
|
||||
uint8_t wifi_mac[6];
|
||||
get_mac_address_raw(wifi_mac); // MSB-first
|
||||
const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
|
||||
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
|
||||
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
|
||||
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
|
||||
// Store LSB-first to match recv_adv_t adv_addr ordering.
|
||||
for (int i = 0; i < 6; i++)
|
||||
this->ble_mac_[i] = ble[5 - i];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan reconciler
|
||||
// Controller scan primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Episode boundary: fresh teardown deadline and error bookkeeping.
|
||||
void BK72xxBLE::reset_teardown_episode_() {
|
||||
this->teardown_since_ms_ = 0;
|
||||
this->restarting_ = false;
|
||||
this->last_release_err_ = 0;
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) {
|
||||
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
|
||||
if (!this->is_active())
|
||||
this->enable();
|
||||
|
||||
const ScanParams params{active, interval, window};
|
||||
// A new episode refills the budget and gets a fresh teardown deadline; a
|
||||
// re-call observing an in-flight bring-up (last result PENDING) must not.
|
||||
if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) {
|
||||
this->pending_since_ms_ = App.get_loop_component_start_time();
|
||||
this->reset_teardown_episode_();
|
||||
if (this->scan_actv_idx_ != 0xFF) {
|
||||
// Already scanning — stop first so this call cleanly restarts with the new
|
||||
// parameters (the BDK cannot start a second scan on a busy activity).
|
||||
this->scan_stop();
|
||||
}
|
||||
this->scan_wanted_ = true;
|
||||
this->requested_ = params;
|
||||
return this->advance_();
|
||||
}
|
||||
|
||||
void BK72xxBLE::scan_stop() {
|
||||
if (this->scan_wanted_) {
|
||||
// A stamp inherited from a stuck restart would fail the stop on its
|
||||
// first advance.
|
||||
this->reset_teardown_episode_();
|
||||
}
|
||||
this->scan_wanted_ = false;
|
||||
this->advance_();
|
||||
}
|
||||
struct scan_param sp;
|
||||
memset(&sp, 0, sizeof(sp));
|
||||
sp.channel_map = 7; // advertising channels 37/38/39
|
||||
sp.interval = interval;
|
||||
sp.window = window;
|
||||
|
||||
bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) {
|
||||
// millis() on both sides: the loop clock is frozen while this blocks.
|
||||
const uint32_t start = millis();
|
||||
while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) {
|
||||
if (millis() - start >= timeout_ms)
|
||||
return false;
|
||||
delay(RECONCILE_RETRY_MS);
|
||||
this->advance_();
|
||||
}
|
||||
return this->last_result_ == ScanOpResult::SETTLED;
|
||||
}
|
||||
|
||||
// Teardown is asynchronous: the handle is kept until an IDLE observation
|
||||
// confirms the radio is idle. A rejection WARNs once per failure streak and
|
||||
// widens the pump gate; the epilogue owns the stuck-teardown deadline.
|
||||
void BK72xxBLE::release_activity_(BdkActivityState state) {
|
||||
const BdkOpResult result =
|
||||
bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_);
|
||||
if (result == BdkOpResult::OK) {
|
||||
this->release_warned_ = false;
|
||||
return;
|
||||
}
|
||||
if (!this->release_warned_) {
|
||||
// A hard error carries its code immediately; the 30 s stuck ERROR follows
|
||||
// if it persists.
|
||||
if (result == BdkOpResult::FAILED) {
|
||||
ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Scan activity release rejected; retrying");
|
||||
}
|
||||
this->release_warned_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged
|
||||
// each interval) and report stuck.
|
||||
bool BK72xxBLE::teardown_stuck_(uint32_t now) {
|
||||
if (this->teardown_since_ms_ == 0) {
|
||||
this->teardown_since_ms_ = now;
|
||||
this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline
|
||||
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
|
||||
if (this->scan_actv_idx_ == 0xFF) {
|
||||
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
|
||||
return false;
|
||||
}
|
||||
if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
|
||||
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
|
||||
if (ret != ERR_SUCCESS) {
|
||||
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
|
||||
this->scan_actv_idx_ = 0xFF;
|
||||
return false;
|
||||
if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) {
|
||||
if (this->last_release_err_ != 0) {
|
||||
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_);
|
||||
} else {
|
||||
// No rejected release this episode: stuck waiting on the controller.
|
||||
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)");
|
||||
}
|
||||
this->teardown_stuck_log_ms_ = now;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// One SDK operation per call toward the latched request; controller state is
|
||||
// read live each time (it changes on the BLE task, so nothing is mirrored).
|
||||
// The epilogue owns all deadlines and episode bookkeeping.
|
||||
ScanOpResult BK72xxBLE::advance_() {
|
||||
if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
|
||||
// Nothing to do; also keeps SDK reads off the pre-enable() path.
|
||||
this->last_result_ = ScanOpResult::SETTLED;
|
||||
return ScanOpResult::SETTLED;
|
||||
void BK72xxBLE::scan_stop() {
|
||||
if (this->scan_actv_idx_ != 0xFF) {
|
||||
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
|
||||
this->scan_actv_idx_ = 0xFF;
|
||||
}
|
||||
const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_);
|
||||
const bool ready = bdk_scan_ready();
|
||||
ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready);
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_advance_ms_ = now;
|
||||
if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) {
|
||||
// Any teardown episode is over (IDLE observed with the controller
|
||||
// settled, or e.g. a mode flip that settled back without ever reaching
|
||||
// IDLE). An IDLE read while an operation is in flight proves nothing —
|
||||
// a stop deferred there must keep its episode running.
|
||||
this->reset_teardown_episode_();
|
||||
this->release_warned_ = false;
|
||||
}
|
||||
if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) {
|
||||
// The mode-change release is observed complete; the rest is a normal
|
||||
// bring-up on a fresh budget.
|
||||
this->restarting_ = false;
|
||||
this->pending_since_ms_ = now;
|
||||
}
|
||||
// Not chained to the clear above: a bring-up waiting at IDLE (create still
|
||||
// in flight) must keep spending its budget.
|
||||
if (result == ScanOpResult::PENDING) {
|
||||
if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) {
|
||||
// A downed radio spends the bring-up budget; exhausting it hands
|
||||
// recovery to the tracker's backoff.
|
||||
if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) {
|
||||
ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start");
|
||||
result = ScanOpResult::FAILED;
|
||||
}
|
||||
} else {
|
||||
// A teardown is pending: a stop, or a mode-change release still in
|
||||
// flight (restarting_); either way the bring-up budget waits.
|
||||
if (this->scan_wanted_)
|
||||
this->pending_since_ms_ = now;
|
||||
if (this->teardown_stuck_(now)) {
|
||||
// Terminal for stop AND restart: the tracker's backoff owns recovery
|
||||
// (a stop's release keeps re-driving from loop(); a restart is
|
||||
// re-requested through scan_start() with a fresh deadline).
|
||||
result = ScanOpResult::FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
this->last_result_ = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
|
||||
if (state == BdkActivityState::IDLE && ready) {
|
||||
// Fully torn down (or never created): the radio is idle. IDLE is trusted
|
||||
// only when the controller is settled — mid-create the slot still reads
|
||||
// IDLE, and dropping the handle then would leak the activity once the
|
||||
// create lands.
|
||||
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
|
||||
return ScanOpResult::SETTLED;
|
||||
}
|
||||
if (!ready) {
|
||||
// Acting mid-operation could delete an activity whose start lands
|
||||
// afterwards, leaking the slot with the radio on; wait.
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
// Settled, so CREATED unambiguously means "never started".
|
||||
this->release_activity_(state);
|
||||
return ScanOpResult::PENDING; // confirmed once IDLE is observed
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
|
||||
if (state == BdkActivityState::STARTED) {
|
||||
if (this->applied_ == this->requested_)
|
||||
return ScanOpResult::SETTLED;
|
||||
// Running with different mode or parameters: tear down (the SDK stop
|
||||
// chain also deletes the activity) and recreate on a later advance.
|
||||
if (ready) {
|
||||
this->release_activity_(state);
|
||||
// Invalidate so a flip back to the old params cannot SETTLE against the
|
||||
// activity being deleted (interval 0 never matches a real request).
|
||||
this->applied_.interval = 0;
|
||||
this->restarting_ = true;
|
||||
}
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (!ready) {
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (state == BdkActivityState::CREATED) {
|
||||
// Fire-and-forget: SETTLED only once a later advance observes the scan
|
||||
// running, so a rejected start is retried rather than silently dead. On
|
||||
// failure the created activity is intact; keep the handle.
|
||||
if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window,
|
||||
this->requested_.active) != BdkOpResult::OK)
|
||||
return ScanOpResult::FAILED;
|
||||
this->applied_ = this->requested_;
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (state == BdkActivityState::OTHER)
|
||||
return ScanOpResult::PENDING; // transitional; settles on a later read
|
||||
|
||||
// IDLE and ready: acquire a slot and create. A kept index is deliberately
|
||||
// reused: SDK delete returns the slot to idle and create requires an idle
|
||||
// slot, so it equals a fresh acquire — while clearing here would orphan a
|
||||
// create still in flight (the BUSY race below).
|
||||
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
|
||||
this->scan_activity_idx_ = bdk_scan_acquire_activity();
|
||||
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX)
|
||||
return ScanOpResult::FAILED;
|
||||
}
|
||||
switch (bdk_scan_create(this->scan_activity_idx_)) {
|
||||
case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot
|
||||
case BdkOpResult::OK:
|
||||
return ScanOpResult::PENDING;
|
||||
case BdkOpResult::FAILED:
|
||||
break;
|
||||
}
|
||||
// Safe to clear (unlike BUSY): acquire is a pure search, so a rejected
|
||||
// create leaves the slot IDLE for re-acquire.
|
||||
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
|
||||
return ScanOpResult::FAILED;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "bdk_scan.h"
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
enum class BLEComponentState : uint8_t {
|
||||
@@ -21,32 +19,11 @@ enum class BLEComponentState : uint8_t {
|
||||
ACTIVE,
|
||||
};
|
||||
|
||||
/// Outcome of one reconciliation step.
|
||||
enum class ScanOpResult : uint8_t {
|
||||
SETTLED, ///< The request is reached: scan observed running, or stopped
|
||||
///< with the activity fully released.
|
||||
PENDING, ///< A step is in flight; loop() keeps advancing — call
|
||||
///< scan_start() again to learn the outcome.
|
||||
FAILED, ///< The controller rejected a step; retry later.
|
||||
};
|
||||
|
||||
/// One scan request: mode plus timing, in BLE units (0.625 ms).
|
||||
struct ScanParams {
|
||||
bool active;
|
||||
uint16_t interval;
|
||||
uint16_t window;
|
||||
bool operator==(const ScanParams &) const = default;
|
||||
};
|
||||
|
||||
/// One advertisement report from the controller.
|
||||
struct BLEScanReport {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
|
||||
int8_t rssi; // signed dBm
|
||||
uint8_t mac[6]; // LSB-first, as the controller delivers it
|
||||
int8_t rssi; // signed dBm
|
||||
uint8_t addr_type;
|
||||
// GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type
|
||||
// (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the
|
||||
// tracker's merger tell the two frames apart.
|
||||
uint8_t evt_type;
|
||||
uint8_t data_len; // bytes valid in data[]
|
||||
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
|
||||
|
||||
@@ -83,7 +60,7 @@ class BK72xxBLE final : public Component {
|
||||
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
|
||||
|
||||
/// Controller BLE address, least-significant octet first (BLE convention).
|
||||
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
|
||||
void get_mac_lsb_first(uint8_t out[6]) const;
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
@@ -92,33 +69,18 @@ class BK72xxBLE final : public Component {
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
#endif
|
||||
|
||||
/// Request a scan (interval/window in 0.625 ms BLE units); enables the
|
||||
/// stack first if needed. PENDING until the scan is observed running —
|
||||
/// loop() keeps advancing, call again to learn the outcome.
|
||||
ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active);
|
||||
/// Request the scanner stopped and the activity released; steps that
|
||||
/// cannot run yet are completed from loop().
|
||||
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
|
||||
/// Enables the stack first if needed. Returns false on controller failure.
|
||||
bool scan_start(uint16_t interval, uint16_t window);
|
||||
/// Stop the controller scan (no-op when not scanning).
|
||||
void scan_stop();
|
||||
/// Drive a requested stop until the radio is observed idle, bounded by
|
||||
/// timeout_ms (for OTA). Returns false if it still has not settled.
|
||||
bool flush_pending_stop(uint32_t timeout_ms);
|
||||
/// Last reconciliation outcome; on FAILED the consumer's retry policy owns
|
||||
/// recovery.
|
||||
ScanOpResult last_scan_result() const { return this->last_result_; }
|
||||
|
||||
/// Internal: buffer one controller report (BDK notice callback, BLE task
|
||||
/// context — bounded copy under the scheduler lock, nothing else).
|
||||
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data,
|
||||
uint16_t data_len);
|
||||
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
|
||||
|
||||
protected:
|
||||
void resolve_mac_();
|
||||
ScanOpResult advance_();
|
||||
ScanOpResult advance_stop_(BdkActivityState state, bool ready);
|
||||
ScanOpResult advance_start_(BdkActivityState state, bool ready);
|
||||
bool teardown_stuck_(uint32_t now);
|
||||
void reset_teardown_episode_();
|
||||
void release_activity_(BdkActivityState state);
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiation —
|
||||
@@ -133,24 +95,10 @@ class BK72xxBLE final : public Component {
|
||||
// allocate() returns nullptr before push() can fail. This prevents leaking a
|
||||
// pool slot on a failed push and keeps release() off the producer path.
|
||||
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
|
||||
// Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
|
||||
uint32_t last_advance_ms_{0};
|
||||
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
|
||||
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
|
||||
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
|
||||
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
|
||||
ScanParams requested_{}; // latched by scan_start()
|
||||
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
|
||||
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention)
|
||||
uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX};
|
||||
bool scan_wanted_{false}; // the latched request is to scan (vs stopped)
|
||||
bool release_warned_{false}; // gates the release WARN; widens the pump gate
|
||||
bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released
|
||||
bool enable_on_boot_{false};
|
||||
// PENDING means advance_() has more to do; loop() drives it, paced and
|
||||
// (for a bring-up) bounded.
|
||||
ScanOpResult last_result_{ScanOpResult::SETTLED};
|
||||
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
|
||||
uint8_t scan_actv_idx_{0xFF};
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
bool enable_on_boot_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
@@ -25,7 +25,6 @@ from esphome.components.ble_device_base import automation as ble_automation
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_ID,
|
||||
@@ -147,9 +146,6 @@ async def stop_scan_action_to_code(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
|
||||
cg.add_define("USE_BK72XX_BLE_TRACKER")
|
||||
# Compiles the shared adv + scan-response merge (the BDK delivers the pair
|
||||
# as separate reports).
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -168,7 +164,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
|
||||
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
|
||||
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
|
||||
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
|
||||
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
await ble_automation.advertise_trigger_to_code(conf, var)
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
@@ -26,15 +27,6 @@ static const char *const TAG = "bk72xx_ble_tracker";
|
||||
// a single WARN is emitted when the retry interval first saturates.
|
||||
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
|
||||
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
|
||||
// Stable-run time before the failure streak clears; reset-on-start would keep
|
||||
// a flapping controller at the 1 s gate.
|
||||
static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000;
|
||||
|
||||
// Radio-idle deadline for the bounded stop drain at OTA start.
|
||||
static constexpr uint32_t OTA_STOP_FLUSH_MS = 100;
|
||||
|
||||
// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part.
|
||||
constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
@@ -44,20 +36,11 @@ void BK72xxBLETracker::setup() {
|
||||
// Receive the controller's scan reports; the controller queues them from the
|
||||
// BLE task and delivers here on the main task.
|
||||
this->parent_->register_scan_listener(this);
|
||||
// Merged (and unmerged) frames go to the shared dispatcher; unclaimed
|
||||
// devices are logged only on one-shot scans (continuous would spam).
|
||||
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
|
||||
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
// scan_requested_ check: an on_boot start_scan latched before this setup()
|
||||
// must keep the retry loop running (rp2/ln882h parity).
|
||||
if (!this->scan_continuous_ && !this->scan_requested_) {
|
||||
// Nothing to time until an explicit start_scan(); it re-enables the loop.
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
@@ -67,54 +50,30 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->scan_requested_before_ota_ = this->scan_requested_;
|
||||
this->stop_scan();
|
||||
// The transfer starves the loop; a deferred stop would leave the radio
|
||||
// scanning for the whole update, so drain it here, bounded.
|
||||
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
|
||||
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
|
||||
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
|
||||
// On success the device reboots, so restore only on a failed/aborted update;
|
||||
// loop() restarts the scan on its next iteration (continuous idle branch).
|
||||
if (this->scan_continuous_before_ota_) {
|
||||
this->scan_continuous_before_ota_ = false;
|
||||
this->scan_continuous_ = true;
|
||||
this->enable_loop(); // stop_scan() parked it
|
||||
}
|
||||
// A one-shot request that was still pending (latched, retrying) when the
|
||||
// OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
|
||||
if (this->scan_requested_before_ota_) {
|
||||
this->scan_requested_before_ota_ = false;
|
||||
this->scan_requested_ = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // USE_OTA_STATE_LISTENER
|
||||
|
||||
void BK72xxBLETracker::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
// Deliver held scannable advertisements whose scan response never arrived —
|
||||
// unmerged after the merger's timeout.
|
||||
if (!this->merger_.empty())
|
||||
this->merger_.sweep(now);
|
||||
|
||||
// Before the drop branch: a drop after a stable run starts a fresh streak.
|
||||
if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
|
||||
this->failed_start_count_ = 0;
|
||||
|
||||
// A terminal failure while we report running recovers via the normal retry
|
||||
// path; the drop charges the backoff so a flapping controller escalates.
|
||||
if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) {
|
||||
ESP_LOGW(TAG, "Controller scan lost; retrying");
|
||||
this->scan_requested_ = true;
|
||||
this->count_failed_start_();
|
||||
this->mark_scan_ended_(now);
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
// One-iteration deferral; all stamps share this iteration's cached
|
||||
// timestamp, so the period check below cannot underflow.
|
||||
// A start that succeeded re-anchored the period timer from a later millis(),
|
||||
// so the stale `now` below would underflow the comparison and fire
|
||||
// on_scan_end() for a scan that just began. Resume next iteration.
|
||||
if (this->try_start_with_backoff_(now))
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +81,11 @@ void BK72xxBLETracker::loop() {
|
||||
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
|
||||
// that never came up (start kept failing) does not fire spurious on_scan_end events.
|
||||
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
|
||||
this->fire_scan_end_();
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
return;
|
||||
@@ -136,14 +99,13 @@ void BK72xxBLETracker::loop() {
|
||||
// would be silent: the scan never runs, stop_scan_() is never reached and
|
||||
// on_scan_end() never fires, leaving period-keyed consumers waiting forever.
|
||||
if (this->scan_requested_ && !this->scan_running_) {
|
||||
// Same one-iteration deferral as the continuous branch.
|
||||
// Same stale-`now` hazard as the continuous branch: start_scan_() stamps
|
||||
// scan_start_time_ from a later millis(), so the duration check below would
|
||||
// underflow and stop the scan in the iteration that started it.
|
||||
if (this->try_start_with_backoff_(now))
|
||||
return;
|
||||
}
|
||||
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
|
||||
// A full-duration run proves the controller healthy even when duration is
|
||||
// shorter than SCAN_STABLE_RESET_MS.
|
||||
this->failed_start_count_ = 0;
|
||||
this->stop_scan_();
|
||||
}
|
||||
}
|
||||
@@ -160,54 +122,32 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
|
||||
// even user-initiated attempts respect the backoff, so a start_scan() action
|
||||
// on a short cadence cannot hammer a failing controller; the attempt stays
|
||||
// inside the failure accounting below either way.
|
||||
// Mid bring-up, observe instead of re-issuing (the hub self-advances). A
|
||||
// SETTLED outcome completes immediately; only fresh attempts after FAILED
|
||||
// are rate-limited.
|
||||
const auto hub = this->parent_->last_scan_result();
|
||||
if (hub == bk72xx_ble::ScanOpResult::PENDING)
|
||||
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
|
||||
if ((!force || this->failed_start_count_ != 0) &&
|
||||
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings))
|
||||
return false;
|
||||
if (hub == bk72xx_ble::ScanOpResult::FAILED) {
|
||||
if (this->start_attempt_open_) {
|
||||
// Our bring-up gave up asynchronously; charge it to the backoff.
|
||||
this->start_attempt_open_ = false;
|
||||
this->count_failed_start_();
|
||||
}
|
||||
if ((!force || this->failed_start_count_ != 0) &&
|
||||
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
|
||||
return false;
|
||||
}
|
||||
this->last_scan_start_attempt_ = now;
|
||||
this->start_scan_();
|
||||
if (!this->scan_running_) {
|
||||
if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) {
|
||||
this->start_attempt_open_ = true;
|
||||
return false; // the controller is still bringing the scan up; not a failure
|
||||
}
|
||||
this->count_failed_start_();
|
||||
}
|
||||
return this->scan_running_;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::count_failed_start_() {
|
||||
if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
++this->failed_start_count_;
|
||||
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
|
||||
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
|
||||
}
|
||||
}
|
||||
return this->scan_running_;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BK72xx BLE Tracker:\n"
|
||||
" Scan Duration: %" PRIu32 " s\n"
|
||||
" Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Type: %s (configured %s)\n"
|
||||
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Type: PASSIVE\n"
|
||||
" Continuous Scanning: %s",
|
||||
this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_,
|
||||
ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
|
||||
this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
|
||||
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
|
||||
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -216,33 +156,31 @@ void BK72xxBLETracker::dump_config() {
|
||||
// listener dispatch run in main-loop context with no cross-task handling here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
|
||||
// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
|
||||
// 5.2 fill it from gapm_ext_adv_report_ind.info).
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
|
||||
static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
|
||||
|
||||
// Demux advertisements vs scan responses into the shared merger: the BDK
|
||||
// delivers the pair as separate reports; a scannable advertisement is held
|
||||
// until its scan response arrives and delivered as one merged frame.
|
||||
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
|
||||
const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
|
||||
if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
|
||||
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
return;
|
||||
// Raw callback (the raw-advertisement path).
|
||||
if (this->raw_advertisement_callback_.is_set()) {
|
||||
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac),
|
||||
.data = report.data,
|
||||
.data_len = report.data_len,
|
||||
.rssi = report.rssi,
|
||||
.addr_type = report.addr_type};
|
||||
this->raw_advertisement_callback_.invoke(adv);
|
||||
}
|
||||
// Stash only while an active scan runs: a passive scan never gets a
|
||||
// response, and after a stop nothing would sweep the merger, so a late
|
||||
// report would surface minutes later as a fresh advertisement.
|
||||
if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
|
||||
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
App.get_loop_component_start_time());
|
||||
return;
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
ble_device_base::ESPBTDevice device;
|
||||
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
bool found = false;
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->parse_device(device)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
|
||||
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
|
||||
// it and the scan is one-shot (continuous scans would spam).
|
||||
if (!found && !this->scan_continuous_)
|
||||
this->discovered_log_.log_device(TAG, device);
|
||||
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,8 +207,7 @@ void BK72xxBLETracker::start_scan() {
|
||||
// against a failing controller, repeated start_scan() calls are rate-limited
|
||||
// like any other attempt.
|
||||
this->scan_requested_ = true;
|
||||
this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
|
||||
this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true);
|
||||
this->try_start_with_backoff_(millis(), /* force= */ true);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::restart_scan_duration() {
|
||||
@@ -281,7 +218,7 @@ void BK72xxBLETracker::restart_scan_duration() {
|
||||
// start_scan action fired more often than scan_duration_ would otherwise
|
||||
// suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
|
||||
// publish) rides on that period.
|
||||
this->scan_start_time_ = App.get_loop_component_start_time();
|
||||
this->scan_start_time_ = millis();
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan() {
|
||||
@@ -294,31 +231,24 @@ void BK72xxBLETracker::stop_scan() {
|
||||
// Internal scan start / stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() {
|
||||
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
|
||||
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
|
||||
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::start_scan_() {
|
||||
if (this->scan_running_)
|
||||
return;
|
||||
|
||||
if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
|
||||
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
|
||||
static_cast<uint16_t>(this->scan_window_)))
|
||||
return;
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const uint32_t now = millis();
|
||||
this->scan_running_ = true;
|
||||
this->scan_requested_ = false; // the latched one-shot request is satisfied
|
||||
this->start_attempt_open_ = false;
|
||||
// failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
|
||||
this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too
|
||||
this->scan_start_time_ = now;
|
||||
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
|
||||
// in non-continuous mode each period is an explicit start, so asymmetric logging
|
||||
// would read as the scanner failing to come back up.
|
||||
ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
|
||||
this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
|
||||
ble_units_to_ms(this->scan_interval_));
|
||||
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
|
||||
this->scan_interval_ * 0.625f);
|
||||
// Re-anchor the on_scan_end period to every successful start — first start (so the
|
||||
// period counts from the scan, not from boot) and every restart after a stop (so
|
||||
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
|
||||
@@ -328,48 +258,18 @@ void BK72xxBLETracker::start_scan_() {
|
||||
this->scan_started_once_ = true;
|
||||
}
|
||||
|
||||
// Deliberate logical/physical split: on_scan_end() reports the tracker's
|
||||
// intent while the hub winds the radio down asynchronously; OTA is the one
|
||||
// path that must wait, and it flushes explicitly.
|
||||
void BK72xxBLETracker::stop_scan_() {
|
||||
this->start_attempt_open_ = false; // an abandoned bring-up is not charged
|
||||
this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
|
||||
if (this->scan_running_) {
|
||||
ESP_LOGD(TAG, "Scan stopped");
|
||||
this->mark_scan_ended_(App.get_loop_component_start_time());
|
||||
}
|
||||
// Park when idle (the hub drives its own teardown); re-check because an
|
||||
// on_scan_end automation may have restarted the scan.
|
||||
if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
// The period re-anchor keeps on_scan_end from double-firing in one iteration.
|
||||
void BK72xxBLETracker::mark_scan_ended_(uint32_t now) {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
this->parent_->scan_stop();
|
||||
this->scan_running_ = false;
|
||||
this->fire_scan_end_();
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::fire_scan_end_() {
|
||||
// Deliver held advertisements whose scan response never came (unmerged)
|
||||
// BEFORE on_scan_end fires.
|
||||
this->merger_.flush();
|
||||
this->dispatcher_.on_scan_end();
|
||||
}
|
||||
|
||||
// true = request latched, not applied: the reconciler applies it
|
||||
// asynchronously and loop() recovers a failed re-arm (ln882h parity).
|
||||
bool BK72xxBLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// The controller reconciler restarts a running scan itself; the scan stays
|
||||
// logically running. An idle scanner picks the mode up on its next start.
|
||||
if (this->scan_running_)
|
||||
this->controller_scan_start_();
|
||||
return true;
|
||||
ESP_LOGD(TAG, "Scan stopped");
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
// window: 30ms
|
||||
// duration: 5min
|
||||
// continuous: true
|
||||
// active: true
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -30,7 +29,6 @@
|
||||
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/ble_device_base/scan_response_merger.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -71,12 +69,6 @@ class BK72xxBLETracker : public Component,
|
||||
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
|
||||
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
|
||||
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
|
||||
/// Set from YAML (scan_parameters.active); runtime mode requests change
|
||||
/// only the resolved mode.
|
||||
void set_scan_active(bool scan_active) {
|
||||
this->scan_active_ = scan_active;
|
||||
this->scan_active_configured_ = scan_active;
|
||||
}
|
||||
/// Set from YAML (scan_parameters.continuous); also the value
|
||||
/// configured_continuous() reports and a bare start_scan action restores.
|
||||
void set_configured_continuous(bool scan_continuous) {
|
||||
@@ -101,29 +93,37 @@ class BK72xxBLETracker : public Component,
|
||||
|
||||
// ---- ble_device_base::BLEHub contract ----
|
||||
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
|
||||
this->dispatcher_.register_listener(listener);
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
this->listeners_.push_back(listener);
|
||||
#endif
|
||||
}
|
||||
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
|
||||
this->dispatcher_.set_raw_advertisement_callback(callback);
|
||||
this->raw_advertisement_callback_ = callback;
|
||||
}
|
||||
static constexpr ble_device_base::HubCapabilities get_capabilities() {
|
||||
// Active scanning is driven through bk72xx_ble's reconciler because the BDK
|
||||
// API itself is passive-only. The controller delivers scan responses as
|
||||
// separate reports; this tracker merges the pair before delivery (shared
|
||||
// ScanResponseMerger, Bluedroid semantics). No GATT client.
|
||||
return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true};
|
||||
// The Beken BDK exposes no active-scan path (passive scanning only), so the
|
||||
// controller never solicits scan responses and never merges them; consumers
|
||||
// relying on scan-response fields (device names) get them only where the
|
||||
// receiver merges per address (Home Assistant does). No GATT client either.
|
||||
// scan_mode_switch stays false for the same reason: with no active-scan
|
||||
// path there is no mode to switch to.
|
||||
return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false};
|
||||
}
|
||||
bool request_scan_mode(bool active) {
|
||||
// Passive-only controller: a passive request is already honored, an active
|
||||
// one cannot be.
|
||||
return !active;
|
||||
}
|
||||
bool request_scan_mode(bool active);
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
void get_adapter_mac(uint8_t out[6]) {
|
||||
uint8_t mac[6];
|
||||
this->parent_->get_mac_lsb_first(mac);
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = mac[5 - i];
|
||||
}
|
||||
bool scan_running() { return this->scan_running_; }
|
||||
bool scan_active() { return this->scan_active_; }
|
||||
bool scan_active() { return false; } // BK72xx scan is passive-only
|
||||
|
||||
// ---- bk72xx_ble::BLEScanListener ----
|
||||
// Delivered by the controller's loop() on the ESPHome main task — the
|
||||
@@ -133,20 +133,15 @@ class BK72xxBLETracker : public Component,
|
||||
protected:
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
void fire_scan_end_();
|
||||
void mark_scan_ended_(uint32_t now);
|
||||
/// Stamp-and-start for every controller scan attempt, so the retry rate
|
||||
/// limit covers all callers.
|
||||
bk72xx_ble::ScanOpResult controller_scan_start_();
|
||||
/// Rate-limited (re)start; true when the scan is running (the caller must
|
||||
/// not reuse a `now` older than the stamps this refreshed). Force and
|
||||
/// backoff rules are documented at the definition.
|
||||
/// Attempt a rate-limited (re)start; returns true when the scan is running,
|
||||
/// which means the caller must not compare its cached millis() against the
|
||||
/// timestamps start_scan_() just refreshed. force bypasses the rate gate for
|
||||
/// an explicit user start only while the failure streak is clean; a failing
|
||||
/// controller rate-limits forced attempts too. Failure accounting always runs.
|
||||
bool try_start_with_backoff_(uint32_t now, bool force = false);
|
||||
void count_failed_start_();
|
||||
|
||||
bool scan_running_{false};
|
||||
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
|
||||
bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once
|
||||
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
|
||||
// Defaults: the BK reference — 30 % duty cycle
|
||||
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
|
||||
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
|
||||
@@ -154,27 +149,30 @@ class BK72xxBLETracker : public Component,
|
||||
uint32_t scan_duration_{300000};
|
||||
bool scan_continuous_{true};
|
||||
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
|
||||
bool scan_active_{true}; // resolved mode; see scan_parameters.active
|
||||
bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
|
||||
bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure
|
||||
#endif
|
||||
uint32_t scan_start_time_{0};
|
||||
|
||||
uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries
|
||||
uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop())
|
||||
uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end()
|
||||
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
|
||||
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
|
||||
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
|
||||
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
|
||||
|
||||
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
|
||||
// All calls run on the main task (the controller queue already crossed
|
||||
// tasks). Merger clock: stash_adv() reads the PARENT's cached loop time
|
||||
// (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this
|
||||
// component's — same App.loop() pass, so the delta stays non-negative and
|
||||
// the 300 ms timeout holds.
|
||||
ble_device_base::ScanResponseMerger merger_;
|
||||
ble_device_base::AdvDispatcher dispatcher_;
|
||||
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Parsed-advertisement consumers registered through ble_device_base.
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiations.
|
||||
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
|
||||
// in ble_device_base, identical output on every tracker backend. Guarded like
|
||||
// its only writer so a no-listener build does not carry an unused vector.
|
||||
ble_device_base::DiscoveredDeviceLog discovered_log_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
@@ -163,9 +163,8 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
|
||||
|
||||
def request_gatt_client() -> None:
|
||||
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
|
||||
claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT;
|
||||
distinct from the proxy's validated connection budget). Called by
|
||||
bluetooth_connection.new_gatt_backend() once per backend instance."""
|
||||
claim one connection slot. Called by bluetooth_proxy once per connection
|
||||
it instantiates on a hub platform."""
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
_request_gatt_connection_slot()
|
||||
|
||||
@@ -247,23 +246,24 @@ def scan_parameters_schema(
|
||||
interval_default: str,
|
||||
*,
|
||||
window_default: str = "30ms",
|
||||
supports_active: bool = False,
|
||||
) -> cv.All:
|
||||
"""Build the scan_parameters value schema shared by all BLE trackers.
|
||||
|
||||
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
|
||||
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
|
||||
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
|
||||
unconditional: active scanning is part of the tracker contract — every
|
||||
current proxy client assumes it, so a passive-only tracker must not share
|
||||
this schema.
|
||||
LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when
|
||||
the tracker supports active scanning; it exposes the `active` option
|
||||
(whose own default is on, esp32_ble_tracker behavior).
|
||||
"""
|
||||
schema = {
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
|
||||
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
|
||||
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
}
|
||||
if supports_active:
|
||||
schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean
|
||||
return cv.All(cv.Schema(schema), validate_scan_parameters)
|
||||
|
||||
|
||||
|
||||
@@ -17,17 +17,11 @@ namespace esphome::ble_device_base {
|
||||
/// client backend.
|
||||
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
|
||||
static constexpr int GATT_ERR_NO_MEMORY = -2;
|
||||
/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency,
|
||||
/// e.g. a service table failing its own bounds checks.
|
||||
static constexpr int GATT_ERR_UNLIKELY = 0x0E;
|
||||
|
||||
/// Safety net shared by every GATT backend: force IDLE when the stack never
|
||||
/// delivers its disconnect completion.
|
||||
static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000;
|
||||
|
||||
/// ATT MTU before negotiation completes (Bluetooth spec default).
|
||||
static constexpr uint16_t DEFAULT_ATT_MTU = 23;
|
||||
|
||||
// Preferred connection parameters shared by every platform's GATT client so
|
||||
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
|
||||
// 0). FAST covers connection setup and service discovery; MEDIUM is the
|
||||
|
||||
@@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
// BLEScanResult's bda is most-significant octet first; the neutral ingest
|
||||
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
|
||||
// address_str_to() then produce exactly the historical esp32 values.
|
||||
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
|
||||
uint8_t mac_lsb_first[6];
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
mac_lsb_first[i] = scan_result.bda[5 - i];
|
||||
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
|
||||
|
||||
@@ -241,7 +241,7 @@ class ESPBTDevice {
|
||||
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
|
||||
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
|
||||
|
||||
uint8_t address_[MAC_ADDRESS_SIZE]{0};
|
||||
uint8_t address_[6]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
// Fixed buffer instead of std::string: no per-advertisement heap churn on
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
// Exactly one GATT backend exists per build, so BLEGattConnection is a
|
||||
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
|
||||
// interface.
|
||||
// A consumer - the hub wrapper streaming the raw database, or a direct
|
||||
// consumer owning a dedicated backend and resolving handles by UUID -
|
||||
// drives it and receives completions through the GattClientListener
|
||||
// interface. All listener calls are delivered on the ESPHome main loop;
|
||||
// borrowed data pointers are valid only for the duration of the call.
|
||||
// A consumer - a streaming consumer that forwards the raw database (the hub
|
||||
// BluetoothConnection wrapper) or a direct consumer owning a dedicated
|
||||
// backend and resolving handles by UUID - drives it and receives
|
||||
// completions through the GattClientListener interface (one build can hold
|
||||
// several consumer types while the backend stays a single non-virtual
|
||||
// class). All listener
|
||||
// calls are delivered on the ESPHome main loop; borrowed data pointers are
|
||||
// valid only for the duration of the call.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// 0 success
|
||||
@@ -79,12 +82,17 @@ struct GattServiceTable {
|
||||
uint16_t descriptor_count{0};
|
||||
};
|
||||
|
||||
/// The event surface a backend delivers completions through - the one place
|
||||
/// with genuine runtime polymorphism (several consumer types, one non-virtual
|
||||
/// backend). Methods default to no-ops; consumers override what they consume.
|
||||
/// No destructor: components are never destroyed.
|
||||
/// on_connection_state carries the negotiated MTU and an HCI status/reason.
|
||||
/// Codegen wires the listener before setup(), so backends skip null checks.
|
||||
/// The event surface a backend delivers completions through. Genuine runtime
|
||||
/// polymorphism lives here - one build can hold several consumer types (the
|
||||
/// proxy's connection wrapper, dedicated-backend components) against the one
|
||||
/// non-virtual backend class - so this is a plain virtual interface: every
|
||||
/// method defaults to a no-op, consumers override what they consume (override
|
||||
/// makes a misspelled name a compile error), and adding an event touches no
|
||||
/// existing consumer. No destructor: components are never destroyed, and
|
||||
/// nothing deletes through this base.
|
||||
/// on_connection_state carries the negotiated MTU and an HCI
|
||||
/// status/disconnect reason. Codegen wires the listener before setup(), so
|
||||
/// backends may call without a null check.
|
||||
class GattClientListener {
|
||||
public:
|
||||
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
|
||||
@@ -102,13 +110,7 @@ class GattClientListener {
|
||||
// rejection); one operation may be outstanding at a time. Semantics beyond
|
||||
// the signatures:
|
||||
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
|
||||
// - gatt_disconnect: also cancels a connect in progress (named to coexist
|
||||
// with a platform stack's own void disconnect() on one backend class).
|
||||
// Nonzero means nothing to tear down and no completion will follow; an
|
||||
// accepted teardown (0) always reaches a terminal on_connection_state.
|
||||
// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not
|
||||
// started closing - the in-flight connect resumes and completes normally.
|
||||
// False once the teardown owns the link (or nothing was scheduled).
|
||||
// - disconnect: also cancels a connect in progress.
|
||||
// - notify_characteristic: local registration only; the CCCD write is the
|
||||
// API client's responsibility (a plain write_descriptor).
|
||||
// - get_service_table/release_services: backend-owned transient storage,
|
||||
@@ -117,17 +119,16 @@ class GattClientListener {
|
||||
// the concrete type, detected by the consumer at compile time) for
|
||||
// arbitrary-size databases; the table then materializes only for consumers
|
||||
// that ask for it.
|
||||
// - completions: connect and gatt_disconnect land in on_connection_state,
|
||||
// - completions: connect and disconnect land in on_connection_state,
|
||||
// discover_services in on_service_discovery_done, pair in
|
||||
// on_pairing_result, reads in on_read_result, notify_characteristic in
|
||||
// on_notify_state, characteristic writes (with and without response) and
|
||||
// descriptor writes in on_write_result.
|
||||
// on_notify_state, characteristic writes with response and descriptor
|
||||
// writes in on_write_result.
|
||||
template<typename T>
|
||||
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
|
||||
conn.set_listener(listener);
|
||||
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
|
||||
{ conn.gatt_disconnect() } -> std::same_as<int>;
|
||||
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
|
||||
{ conn.disconnect() } -> std::same_as<int>;
|
||||
{ conn.discover_services() } -> std::same_as<int>;
|
||||
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
|
||||
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
|
||||
@@ -143,6 +144,51 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
|
||||
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
|
||||
};
|
||||
|
||||
// ---- service table lookup helpers ----
|
||||
//
|
||||
// Neutral, bounds-checked walks over a materialized GattServiceTable for
|
||||
// direct consumers that resolve a known device's handles by UUID (streaming
|
||||
// consumers forward the raw database and never need these). Linear search:
|
||||
// the table exists only between discovery and release_services(), for one
|
||||
// small known device.
|
||||
|
||||
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
|
||||
static constexpr uint16_t CCCD_UUID = 0x2902;
|
||||
|
||||
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
|
||||
for (uint16_t i = 0; i < table.service_count; i++) {
|
||||
if (table.services[i].uuid == uuid)
|
||||
return &table.services[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid) {
|
||||
uint16_t end = service.first_characteristic + service.characteristic_count;
|
||||
if (end > table.characteristic_count)
|
||||
return nullptr;
|
||||
for (uint16_t i = service.first_characteristic; i < end; i++) {
|
||||
if (table.characteristics[i].uuid == uuid)
|
||||
return &table.characteristics[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/// Handle of the characteristic's Client Characteristic Configuration
|
||||
/// descriptor (0x2902), or 0 when it has none.
|
||||
inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
|
||||
uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count;
|
||||
if (end > table.descriptor_count)
|
||||
return 0;
|
||||
const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID);
|
||||
for (uint16_t i = characteristic.first_descriptor; i < end; i++) {
|
||||
if (table.descriptors[i].uuid == cccd_uuid)
|
||||
return table.descriptors[i].handle;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -83,9 +83,9 @@ struct HubCapabilities {
|
||||
/// Today: esp32 and rp2.
|
||||
bool gatt;
|
||||
/// request_scan_mode() is honored at runtime. Distinct from active_scan:
|
||||
/// a passive-only controller can never switch, and a hub may support
|
||||
/// active scanning yet still refuse the runtime switch (esp32_ble_tracker
|
||||
/// drives its mode through its own tracker API).
|
||||
/// a passive-only controller (bk72xx) can never switch, and a hub may
|
||||
/// support active scanning yet still refuse the runtime switch
|
||||
/// (esp32_ble_tracker drives its mode through its own tracker API).
|
||||
bool scan_mode_switch;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
#include "scan_response_merger.h"
|
||||
|
||||
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint8_t data_len, bool raw_only) {
|
||||
// A partial bind is treated as unbound; never dereference half a binding.
|
||||
if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr)
|
||||
return;
|
||||
this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only,
|
||||
*this->scan_continuous_ ? nullptr : this->log_tag_);
|
||||
}
|
||||
|
||||
void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint8_t data_len, uint32_t now) {
|
||||
// One pass: find a same-device entry (deliver + reuse) while remembering the
|
||||
// first free slot as the fallback.
|
||||
PendingAdv *slot = nullptr;
|
||||
PendingAdv *free_slot = nullptr;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (!p.used) {
|
||||
if (free_slot == nullptr)
|
||||
free_slot = &p;
|
||||
continue;
|
||||
}
|
||||
if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
|
||||
// Same device advertised again before its scan response arrived — deliver
|
||||
// the previous advertisement (its scan response is not coming) and reuse
|
||||
// the slot, so no frame is ever lost.
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
slot = &p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = free_slot;
|
||||
if (slot == nullptr) {
|
||||
// Table full — degrade gracefully: deliver the advertisement unmerged.
|
||||
this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
slot->used = true;
|
||||
this->pending_count_++;
|
||||
memcpy(slot->mac, mac, MAC_ADDRESS_SIZE);
|
||||
slot->addr_type = addr_type;
|
||||
slot->rssi = rssi;
|
||||
slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data);
|
||||
memcpy(slot->data, data, slot->data_len);
|
||||
slot->stored_ms = now;
|
||||
}
|
||||
|
||||
void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint8_t data_len) {
|
||||
// Fast-out on the empty table (sweep/flush use the same guard); this is the
|
||||
// hottest caller.
|
||||
if (this->pending_count_ != 0) {
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
|
||||
// Append in place: the slot is released on delivery, so its 62-byte
|
||||
// buffer (legacy adv + scan response) holds the merged frame directly.
|
||||
const uint8_t room = sizeof(p.data) - p.data_len;
|
||||
const uint8_t add = (data_len <= room) ? data_len : room;
|
||||
memcpy(p.data + p.data_len, data, add);
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
// The advertisement's RSSI, not the scan response's (header contract).
|
||||
this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unmatched scan-response: goes out on the raw callback only (HA merges per
|
||||
// address); local listeners/triggers receive each advertisement exactly once
|
||||
// via the merged/plain path above.
|
||||
this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true);
|
||||
}
|
||||
|
||||
void ScanResponseMerger::sweep(uint32_t now) {
|
||||
if (this->pending_count_ == 0)
|
||||
return;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScanResponseMerger::flush() {
|
||||
if (this->pending_count_ == 0)
|
||||
return;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used) {
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
bool raw_only, const char *log_unclaimed_tag) {
|
||||
// Raw callback (the raw-advertisement path). Both full advertisements and
|
||||
// unmatched scan responses (raw_only) are forwarded.
|
||||
if (this->raw_callback_.is_set()) {
|
||||
const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac),
|
||||
.data = data,
|
||||
.data_len = data_len,
|
||||
.rssi = rssi,
|
||||
.addr_type = addr_type};
|
||||
this->raw_callback_.invoke(adv);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Scan-response-only frames are never parsed for local sensors/triggers.
|
||||
if (raw_only)
|
||||
return;
|
||||
ESPBTDevice device;
|
||||
device.from_scan_result(mac, rssi, addr_type, data, data_len);
|
||||
// The listener list holds sensors AND the tracker's automation triggers
|
||||
// (the triggers are listeners, exactly like esp32_ble_tracker), so one
|
||||
// loop feeds both and ORs into `found`.
|
||||
bool found = false;
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->parse_device(device)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found && log_unclaimed_tag != nullptr)
|
||||
this->discovered_log_.log_device(log_unclaimed_tag, device);
|
||||
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
}
|
||||
|
||||
void AdvDispatcher::on_scan_end() {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_SCAN_RESPONSE_MERGER
|
||||
@@ -1,152 +0,0 @@
|
||||
// Shared support for trackers whose controller delivers advertisement and
|
||||
// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates
|
||||
// both into one result before ESPHome sees it):
|
||||
//
|
||||
// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is
|
||||
// held briefly, its scan response is appended on arrival and the pair is
|
||||
// delivered as ONE merged frame. Merged delivery is what the receiving side
|
||||
// is built around: Home Assistant keeps the latest raw frame per device and
|
||||
// skips re-parsing when it is unchanged — split delivery alternates two raw
|
||||
// frames per device and defeats both.
|
||||
//
|
||||
// AdvDispatcher — the delivery half every such tracker repeats: raw
|
||||
// callback, listener parsing, discovered-device log. Trackers delegate
|
||||
// their BLEHub register_listener / set_raw_advertisement_callback here.
|
||||
//
|
||||
// The merger delivers straight into the tracker's AdvDispatcher — bind() wires
|
||||
// the pair once in setup(). Single-task use only (every tracker calls this on
|
||||
// the ESPHome main task). The clock is caller-provided: pass the same clock to
|
||||
// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(),
|
||||
// never mixed).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
// Emitted (cg.add_define) by each tracker that adopts the merger, so builds
|
||||
// whose tracker merges in-stack (esp32) never compile this code.
|
||||
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
|
||||
|
||||
#include "ble_device.h"
|
||||
#include "ble_hub.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// The delivery half of a split-report tracker, shared so the dispatch
|
||||
/// contract (raw-callback ordering, raw_only gate, discovered-log policy)
|
||||
/// lives in one place. Owns the members every tracker otherwise duplicates;
|
||||
/// the tracker's BLEHub methods delegate here.
|
||||
class AdvDispatcher {
|
||||
public:
|
||||
void register_listener(ESPBTDeviceListener *listener) {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
this->listeners_.push_back(listener);
|
||||
#endif
|
||||
}
|
||||
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; }
|
||||
/// Dispatch one (possibly merged) advertisement: the raw callback, and —
|
||||
/// unless raw_only — parsing for listeners/triggers. raw_only marks
|
||||
/// unmatched scan-response frames: forwarded on the raw callback only, never
|
||||
/// parsed for local sensors/triggers (Home Assistant merges per address).
|
||||
/// log_unclaimed_tag: when non-null, a device no listener claimed is logged
|
||||
/// under this tag (esp32_ble_tracker parity: pass the tracker TAG on
|
||||
/// one-shot scans, nullptr on continuous scans, which would spam).
|
||||
void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
bool raw_only, const char *log_unclaimed_tag);
|
||||
/// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup.
|
||||
void on_scan_end();
|
||||
|
||||
protected:
|
||||
RawAdvertisementCallback raw_callback_{};
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Parsed-advertisement consumers registered through ble_device_base.
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiations.
|
||||
StaticVector<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
|
||||
// Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only
|
||||
// writer so a no-listener build does not carry an unused vector.
|
||||
DiscoveredDeviceLog discovered_log_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
class ScanResponseMerger {
|
||||
public:
|
||||
/// Wire the merger's output; call once in the tracker's setup(). Every
|
||||
/// delivered frame goes to dispatcher->dispatch(); scan_continuous is read
|
||||
/// at each delivery (runtime continuous flips are honored) to decide the
|
||||
/// unclaimed-device log tag, so both pointers must outlive the merger —
|
||||
/// tracker members always do.
|
||||
void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) {
|
||||
this->dispatcher_ = dispatcher;
|
||||
this->scan_continuous_ = scan_continuous;
|
||||
this->log_tag_ = log_tag;
|
||||
}
|
||||
/// Hold a scannable advertisement, waiting for its scan response. The
|
||||
/// tracker calls this only when it wants the merge (scannable advertisement
|
||||
/// while an active scan runs) and delivers everything else directly. A
|
||||
/// same-device re-advertisement delivers the held frame (its scan response
|
||||
/// is not coming) and reuses the slot; a full table degrades gracefully to
|
||||
/// unmerged delivery.
|
||||
void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
uint32_t now);
|
||||
/// A scan response arrived: append it to the held advertisement from the
|
||||
/// same device and deliver the pair as one frame. The merged frame reports
|
||||
/// the ADVERTISEMENT's RSSI — every unmerged path reports the
|
||||
/// advertisement's measurement, so a device's RSSI must not jump between two
|
||||
/// measurements depending on merge timing. Unmatched responses are delivered
|
||||
/// raw_only.
|
||||
void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len);
|
||||
/// Timeout flush (call from loop() with the stash_adv() clock): deliver
|
||||
/// held advertisements whose scan response never arrived (device didn't
|
||||
/// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS.
|
||||
void sweep(uint32_t now);
|
||||
/// Deliver every held advertisement now (scan period/scan is ending, before
|
||||
/// on_scan_end fires): unmerged delivery, same as the timeout path.
|
||||
void flush();
|
||||
/// Lets loop() skip the cross-TU sweep() call in the common case (empty:
|
||||
/// passive scan, or every pair already matched).
|
||||
bool empty() const { return this->pending_count_ == 0; }
|
||||
|
||||
private:
|
||||
/// All delivery funnels through here: an unbound merger (bind() not called)
|
||||
/// drops the frame instead of jumping through a null pointer, mirroring the
|
||||
/// guard-before-invoke convention of the ble_hub.h callback slots.
|
||||
void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
bool raw_only);
|
||||
|
||||
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum
|
||||
// as ESP-IDF delivers on ESP32.
|
||||
struct PendingAdv {
|
||||
bool used{false};
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
uint8_t addr_type;
|
||||
int8_t rssi;
|
||||
uint8_t data_len; // <= sizeof(data)
|
||||
uint8_t data[62];
|
||||
uint32_t stored_ms;
|
||||
};
|
||||
// Sized for the unanswered case: a pair that IS answered normally matches
|
||||
// within one report-queue drain, so a slot is held for the full timeout only
|
||||
// by scannable devices that never reply. 8 concurrent such advertisers
|
||||
// before the merge degrades (frames still delivered, just unmerged) at
|
||||
// ~80 B each.
|
||||
static constexpr size_t MAX_PENDING_ADV = 8;
|
||||
// On air a scan response follows its advertisement by T_IFS (150 µs) — the
|
||||
// timeout only covers HOST-side report queuing under WiFi/BLE coexistence,
|
||||
// measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin,
|
||||
// while staying below any device's re-advertising period.
|
||||
static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300;
|
||||
AdvDispatcher *dispatcher_{nullptr};
|
||||
const bool *scan_continuous_{nullptr}; // read at delivery; see bind()
|
||||
const char *log_tag_{nullptr};
|
||||
// pending_count_ mirrors the number of set `used` flags; both are updated
|
||||
// together on every transition.
|
||||
PendingAdv pending_adv_[MAX_PENDING_ADV];
|
||||
uint8_t pending_count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_SCAN_RESPONSE_MERGER
|
||||
@@ -1,29 +1,35 @@
|
||||
"""Per-platform GATT connection backends and the helpers to embed one.
|
||||
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
|
||||
Bluetooth proxy's codegen declares and registers the backend instances
|
||||
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
|
||||
consumer's codegen declares and registers the backend instances — the
|
||||
Bluetooth proxy through its per-slot connection wrappers (a streaming
|
||||
consumer), and direct consumers owning a dedicated backend through
|
||||
gatt_client_config_schema() + new_gatt_backend().
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
|
||||
from esphome.const import (
|
||||
CONF_MAC_ADDRESS,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_RP2,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "bluetooth_connection"
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""ble_device_base plus the platform BLE stack the build's backend
|
||||
registers with (the Bluedroid header includes the tracker's), so
|
||||
consumers need not know. The platform-less arm serves manifest tooling."""
|
||||
registers with, so consumers stay platform-blind. The platform-less arm
|
||||
serves tooling that resolves the manifest without a target."""
|
||||
if CORE.is_esp32:
|
||||
return ["ble_device_base", "esp32_ble_tracker"]
|
||||
if CORE.is_rp2:
|
||||
@@ -37,15 +43,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
|
||||
|
||||
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
|
||||
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and
|
||||
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
|
||||
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
|
||||
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
|
||||
# belongs to the platform stack that owns the pools.
|
||||
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
|
||||
# raising this needs an upstream change (the layer itself supports N).
|
||||
RP2_MAX_CONNECTIONS = 1
|
||||
|
||||
# Slot limits for the hub platforms running the connection-capable proxy;
|
||||
# the backend registry itself is _PLATFORM_BACKENDS below.
|
||||
# Hub platforms with a GATT backend, mapped to their slot limit — the single
|
||||
# registry of which hub platforms run the connection-capable proxy.
|
||||
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
|
||||
|
||||
# The hub-platform wrapper and the backend codegen classes.
|
||||
@@ -56,19 +59,10 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
|
||||
)
|
||||
|
||||
CONF_BACKEND_ID = "backend_id"
|
||||
CONF_ADDRESS_TYPE = "address_type"
|
||||
|
||||
DOMAIN = "bluetooth_connection"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = _ConnectionData()
|
||||
return CORE.data[DOMAIN]
|
||||
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
|
||||
ADDRESS_TYPES = {"public": 0, "random": 1}
|
||||
|
||||
|
||||
def _esp32_schema_fragment() -> cv.Schema:
|
||||
@@ -78,6 +72,8 @@ def _esp32_schema_fragment() -> cv.Schema:
|
||||
|
||||
|
||||
def _rp2_schema_fragment() -> cv.Schema:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
return cv.Schema(
|
||||
{cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)}
|
||||
)
|
||||
@@ -86,35 +82,21 @@ def _rp2_schema_fragment() -> cv.Schema:
|
||||
async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
# The tracker's promote loop owns connect timing; the backend registers
|
||||
# as a raw client (it is the tracker's ESPBTClient).
|
||||
await esp32_ble_tracker.register_raw_client(backend, config)
|
||||
# The tracker's promote loop owns connect timing; the backend's
|
||||
# tracker-facing shim registers as a raw client.
|
||||
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
|
||||
|
||||
|
||||
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import ota
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# The backend drops its link when an OTA starts (esp32 tracker parity).
|
||||
ota.request_ota_state_listeners()
|
||||
# More than one backend outgrows the prebuilt BTstack pools: swap them for
|
||||
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
|
||||
# btstack_memory.cpp. Keyed to backend registrations (the same event that
|
||||
# grows the count that sizes the pools), so single-backend builds emit no
|
||||
# flags and stay byte-identical to previous releases.
|
||||
data = _get_data()
|
||||
data.rp2_backend_count += 1
|
||||
if data.rp2_backend_count == 2:
|
||||
rp2040_ble.add_btstack_pool_overrides()
|
||||
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlatformBackend:
|
||||
"""One platform's backend: codegen class, extra schema keys, and stack
|
||||
registration. The esp32 fragments import their stack lazily because those
|
||||
imports register esp32-only automations as a side effect; rp2040_ble is
|
||||
side-effect-free, so it is imported at module scope (the cap constant
|
||||
needs it there anyway)."""
|
||||
"""One platform's backend: codegen class, extra schema keys (lazy so the
|
||||
platform stack is only imported when targeted), and stack registration."""
|
||||
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
@@ -131,6 +113,9 @@ _PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
|
||||
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
|
||||
}
|
||||
|
||||
# Gates dedicated-backend consumers (cv.only_on).
|
||||
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
|
||||
|
||||
|
||||
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
|
||||
key = platform if platform is not None else CORE.target_platform
|
||||
@@ -155,49 +140,131 @@ def gatt_client_schema(platform: str | None = None) -> cv.Schema:
|
||||
|
||||
def hub_connection_schema(platform: str | None = None) -> cv.Schema:
|
||||
"""Per-slot schema for the proxy's connection wrappers: the wrapper id on
|
||||
top of the backend fragment, plus the component keys (setup_priority and
|
||||
friends now apply to the backend, the slot's real Component). Same
|
||||
platform rules as gatt_client_schema()."""
|
||||
return (
|
||||
gatt_client_schema(platform)
|
||||
.extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
top of the backend fragment. Same platform rules as gatt_client_schema()."""
|
||||
return gatt_client_schema(platform).extend(
|
||||
{cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}
|
||||
)
|
||||
|
||||
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
@dataclass
|
||||
class _SlotLedger:
|
||||
"""GATT connection slots claimed this run, for the platform cap check."""
|
||||
|
||||
consumers: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _ledger() -> _SlotLedger:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = _SlotLedger()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def consume_gatt_slot(consumer: str, count: int = 1):
|
||||
"""Validator claiming GATT connection slots — the one spelling for every
|
||||
claimant (the proxy per configured slot, dedicated backends once). The
|
||||
neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA;
|
||||
esp32 additionally charges the controller's connection budget."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_ledger().consumers.extend([consumer] * count)
|
||||
if CORE.is_esp32:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _validate_slot_totals(config: ConfigType) -> ConfigType:
|
||||
# esp32 has its own controller budget (esp32_ble); the hub platforms cap
|
||||
# at the prebuilt stack's client count, and nothing else counts claims
|
||||
# across components (e.g. a proxy plus a radon_eye_rd200 on rp2).
|
||||
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
|
||||
return config
|
||||
claimed = _ledger().consumers
|
||||
if len(claimed) > cap:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} supports at most {cap} GATT client "
|
||||
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
|
||||
|
||||
|
||||
# The peer keys every dedicated-backend consumer shares: one target device.
|
||||
_PEER_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum(
|
||||
ADDRESS_TYPES, lower=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str):
|
||||
"""Wrap a dedicated-backend consumer's schema so the consumer stays
|
||||
platform-blind: gates on the platforms with a backend, folds in
|
||||
gatt_client_schema() plus the peer keys (mac_address, address_type),
|
||||
and claims the connection slot. `consumer` names the component in
|
||||
slot-exhaustion errors."""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def apply(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# The language-schema dumper runs without a platform; expose the
|
||||
# consumer's keys plus the platform-free peer keys.
|
||||
return base_schema.extend(_PEER_SCHEMA)
|
||||
cv.only_on(GATT_CLIENT_PLATFORMS)(config)
|
||||
schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema())
|
||||
config = schema(config)
|
||||
return consume_gatt_slot(consumer)(config)
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
async def new_gatt_backend(
|
||||
config: ConfigType, *, service_table: bool = True
|
||||
) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema() and register
|
||||
it with its platform stack. The connection slot is claimed at validation
|
||||
(the proxy's slot validators), not here.
|
||||
(gatt_client_config_schema / the proxy's slot validators), not here.
|
||||
|
||||
service_table compiles the on-demand service-table materializer into the
|
||||
backend; direct consumers need it, the streaming proxy does not, so
|
||||
proxy-only builds keep the smaller footprint.
|
||||
"""
|
||||
from esphome.components import ble_device_base
|
||||
|
||||
ble_device_base.request_gatt_client()
|
||||
if service_table:
|
||||
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
|
||||
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
|
||||
# The backend is the slot's real Component: component keys from the
|
||||
# connection entry (setup_priority, ...) apply to it. Consumers whose own
|
||||
# schema carries keys that register_component would misapply to the
|
||||
# backend (e.g. a polling interval) must not put them in this config.
|
||||
await cg.register_component(backend, config)
|
||||
# The backend has no user-facing component options; an empty config keeps
|
||||
# the consumer's own keys (update_interval, ...) off it.
|
||||
await cg.register_component(backend, {})
|
||||
await _backend_entry().register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
# Named so tests can pin the hub entry against bluetooth_proxy's platform
|
||||
# list (this module cannot import bluetooth_proxy to derive it).
|
||||
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
|
||||
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
|
||||
# Every hub platform the proxy admits (the file compiles empty where
|
||||
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
|
||||
# cannot hit a missing-symbol trap here.
|
||||
"bluetooth_connection_hub.cpp": {
|
||||
PlatformFramework.RP2_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
|
||||
}
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS)
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
{
|
||||
"bluetooth_connection_bluedroid.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
# Every hub platform the proxy admits (the file compiles empty where
|
||||
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
|
||||
# cannot hit a missing-symbol trap here.
|
||||
"bluetooth_connection_hub.cpp": {
|
||||
PlatformFramework.RP2_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
|
||||
}
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_bt_device.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -44,7 +44,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#ifdef USE_ESP32
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
#include <esp_err.h>
|
||||
#endif
|
||||
|
||||
// The connection-aware API request handlers are compiled: a GATT backend is
|
||||
// wired by codegen (one slot per connection). This is the single spelling of
|
||||
// that predicate - the hub wrapper and the API request handlers gate on it.
|
||||
// The wrapper serves the proxy's API surface, so it compiles only when a
|
||||
// backend AND the proxy are present; advertisement-only and backend-only
|
||||
// builds get the clean-error handlers instead. Address-scoped maintenance
|
||||
// (unpair, cache clear) still works there through the per-platform free
|
||||
// functions below.
|
||||
// The proxy-serving surface is compiled: a proxy is present and a GATT
|
||||
// backend is wired by codegen (one slot per connection). This is the single
|
||||
// spelling of that predicate - the hub wrapper, the connection-aware API
|
||||
// request handlers, and the Bluedroid in-place streamer all gate on it.
|
||||
// Advertisement-only builds get the clean-error handlers; address-scoped
|
||||
// maintenance (unpair, cache clear) still works there through the
|
||||
// per-platform free functions below. Backend-only builds (a dedicated-backend
|
||||
// consumer without bluetooth_proxy) compile none of this API surface.
|
||||
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
|
||||
#define BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#define BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#endif
|
||||
|
||||
namespace esphome::api {
|
||||
@@ -82,16 +82,6 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
// send_service_ cursor states; >= 0 is the next service index to stream.
|
||||
static constexpr int DONE_SENDING_SERVICES = -2;
|
||||
static constexpr int INIT_SENDING_SERVICES = -3;
|
||||
static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed
|
||||
// Every sentinel must stay below the >= 0 streaming gate and clear of
|
||||
// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused.
|
||||
static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0);
|
||||
static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED &&
|
||||
SERVICES_DONE_PENDING != GATT_NOT_CONNECTED);
|
||||
// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done
|
||||
// delivered near the client's 30 s timeout could land on a fresh request's
|
||||
// empty accumulator and cache as an empty database.
|
||||
static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30;
|
||||
|
||||
// ---- Service-streaming size budget, shared by every platform's streamer ----
|
||||
|
||||
@@ -151,7 +141,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
/// Result of close_service_batch: keep filling the batch or send it now.
|
||||
/// An oversized service is packed alone; a failed (backpressured) send is
|
||||
/// retried from the batch start, so no service is silently skipped.
|
||||
@@ -163,6 +153,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
|
||||
/// cannot drift.
|
||||
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service,
|
||||
uint8_t connection_index, const char *address_str);
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
// The in-place streamer serves the proxy's service-discovery API; backend-only
|
||||
// builds compile without the proxy headers or the streamer.
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#include "bluetooth_connection.h"
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -16,6 +17,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <esp_gatt_common_api.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
@@ -31,10 +34,19 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
|
||||
using esp32_ble_tracker::ClientState;
|
||||
using esp32_ble_tracker::ConnectionType;
|
||||
|
||||
// ---- tracker surface ----
|
||||
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
|
||||
|
||||
void BluedroidGattClient::connect() { this->tracker_connect_(); }
|
||||
void BluedroidGattClient::disconnect() { this->gatt_disconnect(); }
|
||||
// ---- shim forwarders ----
|
||||
|
||||
bool BluedroidTrackerShim::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
return this->engine_->handle_gattc_event_(event, gattc_if, param);
|
||||
}
|
||||
void BluedroidTrackerShim::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
this->engine_->handle_gap_event_(event, param);
|
||||
}
|
||||
void BluedroidTrackerShim::connect() { this->engine_->tracker_connect_(); }
|
||||
void BluedroidTrackerShim::disconnect() { this->engine_->disconnect(); }
|
||||
|
||||
// ---- component ----
|
||||
|
||||
@@ -45,45 +57,30 @@ void BluedroidGattClient::setup() {
|
||||
|
||||
void BluedroidGattClient::loop() {
|
||||
if (!esp32_ble::global_ble->is_active()) {
|
||||
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
|
||||
// frees its slot, then re-register the app on the next enable.
|
||||
auto down_st = this->state();
|
||||
if (down_st != ClientState::IDLE && down_st != ClientState::INIT) {
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
|
||||
}
|
||||
this->set_state(ClientState::INIT);
|
||||
// Stack down: re-register the app on the next enable.
|
||||
this->set_state_(ClientState::INIT);
|
||||
return;
|
||||
}
|
||||
auto st = this->state();
|
||||
auto st = this->state_();
|
||||
if (st == ClientState::INIT) {
|
||||
// Parity with BLEClientBase: a failed registration marks the slot
|
||||
// failed and idles it without retry.
|
||||
auto ret = esp_ble_gattc_app_register(this->app_id);
|
||||
auto ret = esp_ble_gattc_app_register(this->shim_.app_id);
|
||||
if (ret) {
|
||||
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret);
|
||||
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret);
|
||||
this->mark_failed();
|
||||
}
|
||||
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
|
||||
this->set_state_(ClientState::IDLE);
|
||||
} else if (st == ClientState::IDLE) {
|
||||
// The loop only drives the bootstrap and the disconnect safety timeout.
|
||||
this->disable_loop();
|
||||
} else if (st == ClientState::DISCONNECTING &&
|
||||
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGE(TAG, "[%d] Timeout waiting for CLOSE_EVT, forcing IDLE", this->connection_index_);
|
||||
// Release before idling: unconditional disconnect does not release, and a
|
||||
// lost CLOSE/DISCONNECT would otherwise leak the table and the cache.
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
} else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) {
|
||||
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
|
||||
// teardown whose OPEN_EVT never arrives.
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
|
||||
// Release before idling: a lost completion must not leak the cache.
|
||||
this->release_services();
|
||||
this->set_idle_(); // also clears want_disconnect_
|
||||
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
|
||||
}
|
||||
} else {
|
||||
// The loop stays on while a link exists (stack-down watch, pre-started
|
||||
// search flush); it settles only back at IDLE.
|
||||
this->deliver_pending_search_();
|
||||
if (this->state() == ClientState::IDLE) {
|
||||
this->disable_loop();
|
||||
}
|
||||
this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +94,10 @@ void BluedroidGattClient::dump_config() {
|
||||
// ---- contract ops ----
|
||||
|
||||
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
// Only from idle: clobbering DISCONNECTING would open a new link the
|
||||
// stale CLOSE_EVT then tears down.
|
||||
if (this->state() != ClientState::IDLE) {
|
||||
// Refuse anything but a fully idle slot. Clobbering DISCONNECTING with
|
||||
// DISCOVERED would let the tracker open a new link while the old one is
|
||||
// still closing - the stale CLOSE_EVT then tears the new attempt down.
|
||||
if (this->state_() != ClientState::IDLE) {
|
||||
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
|
||||
return ESP_GATT_BUSY;
|
||||
}
|
||||
@@ -107,12 +105,12 @@ int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
this->remote_addr_type_ = addr_type;
|
||||
// Hand the request to the tracker's promote loop: it stops the scan, raises
|
||||
// coex, and calls tracker_connect_() - the tracker owns connect timing here.
|
||||
this->set_state(ClientState::DISCOVERED);
|
||||
this->set_state_(ClientState::DISCOVERED);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::tracker_connect_() {
|
||||
auto st = this->state();
|
||||
auto st = this->state_();
|
||||
if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) {
|
||||
ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_);
|
||||
return;
|
||||
@@ -122,35 +120,30 @@ void BluedroidGattClient::tracker_connect_() {
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_);
|
||||
// Per-attempt latches; the search machine is reset by set_idle_(), the
|
||||
// one door back to IDLE.
|
||||
this->services_released_ = false;
|
||||
this->seen_mtu_ = false;
|
||||
this->mtu_failed_ = false;
|
||||
this->enable_loop();
|
||||
this->set_state(ClientState::CONNECTING);
|
||||
this->set_state_(ClientState::CONNECTING);
|
||||
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
|
||||
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
|
||||
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL,
|
||||
FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT));
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
|
||||
FAST_CONN_TIMEOUT);
|
||||
} else {
|
||||
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL,
|
||||
MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT));
|
||||
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0,
|
||||
MEDIUM_CONN_TIMEOUT);
|
||||
}
|
||||
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_,
|
||||
static_cast<esp_ble_addr_type_t>(this->remote_addr_type_), true);
|
||||
if (ret) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_open", ret);
|
||||
// CONNECT_EVT never fired; nothing to close.
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ret);
|
||||
// CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE.
|
||||
this->set_state_(ClientState::IDLE);
|
||||
this->report_connection_state_(false, ret);
|
||||
}
|
||||
}
|
||||
|
||||
int BluedroidGattClient::gatt_disconnect() {
|
||||
auto st = this->state();
|
||||
int BluedroidGattClient::disconnect() {
|
||||
auto st = this->state_();
|
||||
if (st == ClientState::DISCONNECTING) {
|
||||
return 0;
|
||||
}
|
||||
@@ -161,15 +154,12 @@ int BluedroidGattClient::gatt_disconnect() {
|
||||
}
|
||||
if (st == ClientState::DISCOVERED) {
|
||||
// Parked for the tracker promote loop, never opened.
|
||||
this->set_idle_();
|
||||
this->set_state_(ClientState::IDLE);
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
|
||||
ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_);
|
||||
this->want_disconnect_ = true;
|
||||
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
|
||||
this->disconnecting_started_ = millis();
|
||||
this->enable_loop();
|
||||
this->shim_.schedule_disconnect();
|
||||
return 0;
|
||||
}
|
||||
this->unconditional_disconnect_();
|
||||
@@ -179,11 +169,7 @@ int BluedroidGattClient::gatt_disconnect() {
|
||||
void BluedroidGattClient::unconditional_disconnect_() {
|
||||
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
// Terminal state now rather than leaning on the scheduled-teardown timer.
|
||||
ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_);
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
|
||||
return;
|
||||
}
|
||||
auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_);
|
||||
@@ -194,58 +180,20 @@ void BluedroidGattClient::unconditional_disconnect_() {
|
||||
this->set_disconnecting_();
|
||||
}
|
||||
|
||||
bool BluedroidGattClient::cancel_gatt_disconnect() {
|
||||
// Only a scheduled teardown (want_disconnect_ latched while the open is
|
||||
// still in flight) is cancellable; once closing started the terminal
|
||||
// report settles the race.
|
||||
if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) {
|
||||
return false;
|
||||
}
|
||||
this->want_disconnect_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int BluedroidGattClient::discover_services() {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
switch (this->search_state_) {
|
||||
case SearchState::PRESTARTED:
|
||||
// The pending SEARCH_CMPL reports once it lands.
|
||||
this->search_state_ = SearchState::CLAIMED;
|
||||
return 0;
|
||||
case SearchState::PRESTART_DONE:
|
||||
// Already landed: the flush after the connected report delivers
|
||||
// (loop() covers a claim made outside that event drain).
|
||||
this->search_state_ = SearchState::REPORT_PENDING;
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
case SearchState::CLAIMED:
|
||||
case SearchState::REPORT_PENDING:
|
||||
return 0; // One completion is already owed to this claimant.
|
||||
case SearchState::NONE:
|
||||
break;
|
||||
}
|
||||
int err = this->check_and_log_error_("esp_ble_gattc_search_service",
|
||||
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
|
||||
if (err == 0) {
|
||||
this->search_state_ = SearchState::CLAIMED;
|
||||
}
|
||||
return err;
|
||||
return this->check_and_log_error_("esp_ble_gattc_search_service",
|
||||
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
|
||||
}
|
||||
|
||||
int BluedroidGattClient::read_characteristic(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
|
||||
handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
// The BTC layer copies the payload immediately, so the const_cast is safe.
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char",
|
||||
@@ -255,18 +203,12 @@ int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *da
|
||||
}
|
||||
|
||||
int BluedroidGattClient::read_descriptor(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_read_char_descr",
|
||||
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char_descr",
|
||||
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
@@ -274,9 +216,6 @@ int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data,
|
||||
}
|
||||
|
||||
int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
// Local registration only; the CCCD write is the API client's responsibility.
|
||||
if (enable) {
|
||||
return this->check_and_log_error_("esp_ble_gattc_register_for_notify",
|
||||
@@ -286,12 +225,7 @@ int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
|
||||
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
|
||||
}
|
||||
|
||||
int BluedroidGattClient::pair() {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT);
|
||||
}
|
||||
int BluedroidGattClient::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
|
||||
|
||||
int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
|
||||
uint16_t timeout) {
|
||||
@@ -300,18 +234,190 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
// Always set: terminates any in-flight stream on every cache config.
|
||||
this->services_released_ = true;
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
this->free_service_table_();
|
||||
#endif
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
// A failed clean leaves a stale database the next connection could serve
|
||||
// as authoritative. A disabled stack invalidates its own cache; skip the
|
||||
// meaningless call instead of warning on every OTA/ble.disable teardown.
|
||||
if (esp32_ble::global_ble->is_active()) {
|
||||
this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_));
|
||||
}
|
||||
// Only the cache clean makes the stack's database unsafe to walk.
|
||||
this->services_released_ = true;
|
||||
esp_ble_gattc_cache_clean(this->remote_bda_);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
|
||||
if (this->table_storage_ == nullptr &&
|
||||
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
|
||||
return {};
|
||||
}
|
||||
return this->table_view_();
|
||||
}
|
||||
|
||||
// The view is carved from the storage block and the counts on each call
|
||||
// (a cold path) rather than cached, saving a per-instance table member.
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const {
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic);
|
||||
return {reinterpret_cast<const ble_device_base::GattService *>(this->table_storage_),
|
||||
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
|
||||
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes),
|
||||
this->service_total_,
|
||||
this->table_char_total_,
|
||||
this->table_desc_total_};
|
||||
}
|
||||
|
||||
void BluedroidGattClient::free_service_table_() {
|
||||
if (this->table_storage_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
allocator.deallocate(this->table_storage_, 0);
|
||||
this->table_storage_ = nullptr;
|
||||
this->table_char_total_ = 0;
|
||||
this->table_desc_total_ = 0;
|
||||
}
|
||||
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
|
||||
// Shared enumeration for both table-build passes: an identical walk order
|
||||
// is what lets the counting pass size the block the filling pass fills.
|
||||
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
|
||||
for (uint16_t s = 0; s < this->service_total_; s++) {
|
||||
esp_gattc_service_elem_t svc;
|
||||
uint16_t svc_count = 1;
|
||||
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK ||
|
||||
svc_count == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!on_service(s, svc)) {
|
||||
return false;
|
||||
}
|
||||
uint16_t svc_chars = 0;
|
||||
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle,
|
||||
svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) {
|
||||
return false;
|
||||
}
|
||||
for (uint16_t c = 0; c < svc_chars; c++) {
|
||||
esp_gattc_char_elem_t chr;
|
||||
uint16_t char_count = 1;
|
||||
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
|
||||
&char_count, c);
|
||||
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (status != ESP_GATT_OK || char_count == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!on_char(svc, chr)) {
|
||||
return false;
|
||||
}
|
||||
for (uint16_t d = 0;; d++) {
|
||||
esp_gattc_descr_elem_t desc;
|
||||
uint16_t desc_count = 1;
|
||||
auto desc_status =
|
||||
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
|
||||
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (desc_status != ESP_GATT_OK || desc_count == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!on_desc(chr, desc)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidGattClient::build_service_table_() {
|
||||
// Pass 1: count, so one exact-size block holds the whole table.
|
||||
uint16_t char_total = 0;
|
||||
uint16_t desc_total = 0;
|
||||
bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
|
||||
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
|
||||
char_total++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
|
||||
desc_total++;
|
||||
return true;
|
||||
});
|
||||
if (!counted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The arrays share one block; carving stays aligned because each struct's
|
||||
// strictest member is the UUID and array sizes are multiples of it.
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
|
||||
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
this->table_storage_ = allocator.allocate(total_bytes);
|
||||
if (this->table_storage_ == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_,
|
||||
static_cast<unsigned>(total_bytes));
|
||||
return false;
|
||||
}
|
||||
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
|
||||
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
|
||||
auto *descriptors =
|
||||
reinterpret_cast<ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes);
|
||||
|
||||
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
|
||||
// means the cached database changed between the passes; fail the build
|
||||
// rather than serve an inconsistent table (the consumer retries).
|
||||
uint16_t char_index = 0;
|
||||
uint16_t desc_index = 0;
|
||||
ble_device_base::GattService *cur_service = nullptr;
|
||||
ble_device_base::GattCharacteristic *cur_char = nullptr;
|
||||
bool filled = this->walk_database_(
|
||||
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
|
||||
cur_service = &services[s];
|
||||
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
|
||||
cur_service->start_handle = svc.start_handle;
|
||||
cur_service->end_handle = svc.end_handle;
|
||||
cur_service->first_characteristic = char_index;
|
||||
cur_service->characteristic_count = 0;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
|
||||
if (char_index >= char_total) {
|
||||
return false;
|
||||
}
|
||||
cur_char = &characteristics[char_index++];
|
||||
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
|
||||
cur_char->value_handle = chr.char_handle;
|
||||
// Bluedroid addresses descriptors by characteristic handle, so the
|
||||
// table's end_handle only needs the service-bounded upper bound.
|
||||
cur_char->end_handle = svc.end_handle;
|
||||
cur_char->properties = chr.properties;
|
||||
cur_char->first_descriptor = desc_index;
|
||||
cur_char->descriptor_count = 0;
|
||||
cur_service->characteristic_count++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
|
||||
if (desc_index >= desc_total) {
|
||||
return false;
|
||||
}
|
||||
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
|
||||
descriptors[desc_index].handle = desc.handle;
|
||||
desc_index++;
|
||||
cur_char->descriptor_count++;
|
||||
return true;
|
||||
});
|
||||
if (!filled || char_index != char_total || desc_index != desc_total) {
|
||||
this->free_service_table_();
|
||||
return false;
|
||||
}
|
||||
this->table_char_total_ = char_total;
|
||||
this->table_desc_total_ = desc_total;
|
||||
return true;
|
||||
}
|
||||
#endif // USE_BLE_GATT_SERVICE_TABLE
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
|
||||
@@ -319,19 +425,21 @@ bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
|
||||
}
|
||||
|
||||
void BluedroidGattClient::set_idle_() {
|
||||
this->set_state(ClientState::IDLE);
|
||||
this->set_state_(ClientState::IDLE);
|
||||
this->conn_id_ = UNSET_CONN_ID;
|
||||
this->search_state_ = SearchState::NONE;
|
||||
this->search_status_ = 0;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::set_disconnecting_() {
|
||||
this->disconnecting_started_ = millis();
|
||||
this->set_state(ClientState::DISCONNECTING);
|
||||
this->set_state_(ClientState::DISCONNECTING);
|
||||
// The loop may be disabled while idle; the safety timeout needs it.
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
|
||||
this->listener_->on_connection_state(connected, this->mtu_, error);
|
||||
}
|
||||
|
||||
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
|
||||
uint16_t timeout, const char *param_type) {
|
||||
esp_ble_conn_update_params_t conn_params = {{0}};
|
||||
@@ -357,15 +465,9 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
|
||||
|
||||
// ---- service streaming ----
|
||||
|
||||
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
void BluedroidGattClient::handle_search_cmpl_() {
|
||||
// Step down from the fast discovery params.
|
||||
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
|
||||
if (status != ESP_GATT_OK) {
|
||||
// A failed discovery reads as a clean zero from the count calls below;
|
||||
// honoring the event status stops it becoming an authoritative empty
|
||||
// list.
|
||||
return status;
|
||||
}
|
||||
uint16_t primary = 0;
|
||||
uint16_t secondary = 0;
|
||||
auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE,
|
||||
@@ -373,50 +475,34 @@ int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE,
|
||||
0x0001, 0xFFFF, 0, &secondary);
|
||||
if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) {
|
||||
// A failed count must not become an authoritative empty database.
|
||||
auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status);
|
||||
return count_status;
|
||||
// A failed count must not become an authoritative empty database - V3
|
||||
// clients cache the streamed result permanently.
|
||||
auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status);
|
||||
this->listener_->on_service_discovery_done(status);
|
||||
return;
|
||||
}
|
||||
this->service_total_ = primary + secondary;
|
||||
return 0;
|
||||
this->listener_->on_service_discovery_done(0);
|
||||
}
|
||||
|
||||
// Reports a completed search once claimed; delivery consumes the state so
|
||||
// a re-discovery issues a real search.
|
||||
void BluedroidGattClient::deliver_pending_search_() {
|
||||
if (this->search_state_ != SearchState::REPORT_PENDING)
|
||||
return;
|
||||
this->search_state_ = SearchState::NONE;
|
||||
this->listener_->on_service_discovery_done(this->search_status_);
|
||||
}
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
// The wrapper's compile-time streamer detection must keep finding this
|
||||
// method; a signature drift would silently fall back to the table streamer,
|
||||
// which proxy builds compile without a materializer.
|
||||
static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); });
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
if (this->services_released_) {
|
||||
// Released under the stream: park without services-done so a partial
|
||||
// list is never cached as authoritative (the client retries after its
|
||||
// GetServices timeout).
|
||||
ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_);
|
||||
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
if (conn.send_service_ >= this->service_total_) {
|
||||
conn.proxy_->send_gatt_services_done(conn.address_);
|
||||
this->release_services();
|
||||
conn.send_services_done_();
|
||||
return;
|
||||
}
|
||||
|
||||
// The subscriber vanished mid-stream.
|
||||
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
|
||||
// sending services-done (a resubscribing client gets silence and its 30 s
|
||||
// timeout, never an authoritative partial list).
|
||||
auto *api_conn = conn.proxy_->get_api_connection();
|
||||
if (api_conn == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_);
|
||||
conn.park_service_stream_();
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
this->release_services();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,21 +515,19 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
while (conn.send_service_ < this->service_total_) {
|
||||
esp_gattc_service_elem_t service_result;
|
||||
uint16_t svc_count = 1;
|
||||
esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result,
|
||||
&svc_count, conn.send_service_);
|
||||
if (svc_status != ESP_GATT_OK || svc_count == 0) {
|
||||
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &svc_count,
|
||||
conn.send_service_) != ESP_GATT_OK ||
|
||||
svc_count == 0) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_,
|
||||
conn.address_str_, conn.send_service_);
|
||||
conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
uint16_t total_char_count = 0;
|
||||
auto char_count_status =
|
||||
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
|
||||
service_result.start_handle, service_result.end_handle, 0, &total_char_count);
|
||||
if (char_count_status != ESP_GATT_OK) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status);
|
||||
conn.abort_service_stream(char_count_status);
|
||||
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
|
||||
service_result.start_handle, service_result.end_handle, 0,
|
||||
&total_char_count) != ESP_GATT_OK) {
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -469,12 +553,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
uint16_t cc = 1;
|
||||
auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
|
||||
service_result.end_handle, &char_result, &cc, char_offset);
|
||||
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (char_status != ESP_GATT_OK || cc == 0) {
|
||||
// An early terminator contradicts the count from the same cache;
|
||||
// never stream a silently truncated list.
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
|
||||
conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND);
|
||||
return;
|
||||
if (char_status != ESP_GATT_OK) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
service_resp.characteristics.emplace_back();
|
||||
auto &characteristic_resp = service_resp.characteristics.back();
|
||||
@@ -490,7 +578,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
// Abort rather than stream the characteristic descriptor-less: a
|
||||
// missing CCCD in a cached database breaks notifications for good.
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status);
|
||||
conn.abort_service_stream(desc_count_status);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
if (total_desc_count > 0) {
|
||||
@@ -501,10 +589,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
uint16_t dc = 1;
|
||||
auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle,
|
||||
&desc_result, &dc, desc_offset);
|
||||
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (desc_status != ESP_GATT_OK || dc == 0) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
|
||||
conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND);
|
||||
return;
|
||||
if (desc_status != ESP_GATT_OK) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
|
||||
conn.send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
characteristic_resp.descriptors.emplace_back();
|
||||
auto &descriptor_resp = characteristic_resp.descriptors.back();
|
||||
@@ -531,20 +625,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
conn.send_service_ = batch_start;
|
||||
}
|
||||
}
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
// ---- events ----
|
||||
|
||||
void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
auto st = this->state();
|
||||
auto st = this->state_();
|
||||
if (st == ClientState::IDLE) {
|
||||
// Late OPEN_EVT after the slot went IDLE (open-error race, or the
|
||||
// teardown net gave up): close a won link, never resurrect the slot.
|
||||
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status);
|
||||
if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) {
|
||||
// A failed close here leaks a live link nothing tracks; make it heard.
|
||||
this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id));
|
||||
}
|
||||
// IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an
|
||||
// error and the slot went IDLE; do not resurrect it.
|
||||
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, param->open.status);
|
||||
return;
|
||||
}
|
||||
if (st != ClientState::CONNECTING) {
|
||||
@@ -554,49 +644,36 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
this->log_gattc_warning_("Connection open", param->open.status);
|
||||
// Never established, CLOSE_EVT may not follow.
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, param->open.status);
|
||||
this->report_connection_state_(false, param->open.status);
|
||||
return;
|
||||
}
|
||||
if (this->disconnect_pending()) {
|
||||
// Open resolved with a teardown scheduled: close now (conn_id_ stays set
|
||||
// so CLOSE_EVT still matches).
|
||||
if (this->shim_.disconnect_pending()) {
|
||||
// Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches.
|
||||
this->unconditional_disconnect_();
|
||||
return;
|
||||
}
|
||||
this->set_state(ClientState::CONNECTED);
|
||||
this->set_state_(ClientState::CONNECTED);
|
||||
ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_);
|
||||
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
|
||||
this->set_state(ClientState::ESTABLISHED);
|
||||
// No discovery phase: report immediately with the default MTU. The
|
||||
// cached path never waits for (or reports) the exchange - seen_mtu_
|
||||
// suppresses the CFG_MTU report, matching the previous esp32 behavior.
|
||||
this->set_state_(ClientState::ESTABLISHED);
|
||||
// No discovery phase: report immediately; the MTU report below is
|
||||
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
|
||||
// matching the previous esp32 behavior).
|
||||
this->seen_mtu_ = true;
|
||||
this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0);
|
||||
} else {
|
||||
// Discovery-bound connection: start the search now so it overlaps the
|
||||
// MTU exchange. On a refusal fall back to the serialized path - the
|
||||
// consumer's own discover_services() call retries the real search.
|
||||
if (this->check_and_log_error_("esp_ble_gattc_search_service",
|
||||
esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) {
|
||||
this->search_state_ = SearchState::PRESTARTED;
|
||||
}
|
||||
if (this->mtu_failed_ && !this->seen_mtu_) {
|
||||
// Refused MTU request: report with the default so the consumer
|
||||
// proceeds.
|
||||
this->seen_mtu_ = true;
|
||||
this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0);
|
||||
this->deliver_pending_search_();
|
||||
}
|
||||
this->report_connection_state_(true, 0);
|
||||
// Settled: only the disconnect safety net needs the loop, and
|
||||
// set_disconnecting_() re-enables it.
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) {
|
||||
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_() == ClientState::CONNECTED) {
|
||||
ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason);
|
||||
}
|
||||
if (this->state() == ClientState::IDLE) {
|
||||
if (this->state_() == ClientState::IDLE) {
|
||||
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
|
||||
return;
|
||||
}
|
||||
@@ -608,9 +685,9 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
|
||||
this->set_disconnecting_();
|
||||
}
|
||||
|
||||
bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id)
|
||||
if (event == ESP_GATTC_REG_EVT && this->shim_.app_id != param->reg.app_id)
|
||||
return false;
|
||||
if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_)
|
||||
return false;
|
||||
@@ -633,8 +710,6 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
|
||||
if (ret) {
|
||||
this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret);
|
||||
// No CFG_MTU_EVT will follow; OPEN_EVT reports with the default.
|
||||
this->mtu_failed_ = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -650,17 +725,13 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
if (param->cfg_mtu.status != ESP_GATT_OK) {
|
||||
// Warn only; a disconnect will follow if the link is dead.
|
||||
this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status);
|
||||
} else {
|
||||
this->mtu_ = param->cfg_mtu.mtu;
|
||||
}
|
||||
if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) {
|
||||
// Teardown owns the link: suppress the connected report here like
|
||||
// OPEN_EVT and SEARCH_CMPL do; the terminal report settles it.
|
||||
if (!this->seen_mtu_) {
|
||||
this->seen_mtu_ = true;
|
||||
// The connected report waited for the MTU; forwarded, not stored.
|
||||
this->listener_->on_connection_state(
|
||||
true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0);
|
||||
// The consumer requests discovery from inside that report; when the
|
||||
// pre-started search already finished, complete it in the same drain.
|
||||
this->deliver_pending_search_();
|
||||
// The connected report waited for the MTU so HA never sees 23.
|
||||
this->report_connection_state_(true, 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -677,23 +748,17 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
this->set_idle_();
|
||||
// The one connected=false report: the wrapper frees the slot on it,
|
||||
// so it must not fire before the controller finished closing.
|
||||
this->listener_->on_connection_state(false, 0, param->close.reason);
|
||||
this->report_connection_state_(false, param->close.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
if (this->conn_id_ != param->search_cmpl.conn_id)
|
||||
return false;
|
||||
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
|
||||
if (this->state() == ClientState::DISCONNECTING) {
|
||||
// Teardown owns the link; the result is never delivered, skip the
|
||||
// work.
|
||||
break;
|
||||
}
|
||||
this->search_status_ = this->handle_search_cmpl_(static_cast<esp_gatt_status_t>(param->search_cmpl.status));
|
||||
this->search_state_ =
|
||||
this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE;
|
||||
this->set_state(ClientState::ESTABLISHED);
|
||||
this->deliver_pending_search_();
|
||||
this->set_state_(ClientState::ESTABLISHED);
|
||||
this->handle_search_cmpl_();
|
||||
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
|
||||
this->disable_loop();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
@@ -737,18 +802,13 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_SEC_REQ_EVT: {
|
||||
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
|
||||
break;
|
||||
// Always accept; a refused response means no AUTH_CMPL, so answer the
|
||||
// pairing request with the failure.
|
||||
int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp",
|
||||
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true));
|
||||
if (sec_err != 0) {
|
||||
this->listener_->on_pairing_result(sec_err);
|
||||
}
|
||||
// Always accept a server-initiated security request.
|
||||
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection
|
||||
// wrapper. Not a BLEClientBase: the tracker's promote loop owns
|
||||
// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only
|
||||
// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in
|
||||
// the tracker-invoked connect() override.
|
||||
// parks the address in DISCOVERED and the real esp_ble_gattc_open happens in
|
||||
// the tracker-invoked shim connect(). The shim exists because the tracker's
|
||||
// ESPBTClient::disconnect() returns void while the contract's returns int -
|
||||
// one class cannot carry both.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -11,6 +13,8 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -20,37 +24,16 @@
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluedroidGattClient;
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
class BluetoothConnection;
|
||||
#endif
|
||||
|
||||
// One class carries both halves: the tracker's ESPBTClient surface (its
|
||||
// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the
|
||||
// virtual connect()/disconnect()) and the neutral contract ops. The
|
||||
// contract's teardown op is named gatt_disconnect() because the tracker's
|
||||
// void disconnect() cannot overload with an int-returning twin.
|
||||
class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component {
|
||||
// The tracker-facing half: owns the ClientState the promote loop reads and
|
||||
// forwards events/commands to the engine.
|
||||
class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient {
|
||||
public:
|
||||
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
|
||||
|
||||
// Lifecycle of one connection attempt's service search.
|
||||
enum class SearchState : uint8_t {
|
||||
NONE, // no search this attempt
|
||||
PRESTARTED, // issued at OPEN_EVT, no claimant yet
|
||||
PRESTART_DONE, // completed with search_status_ latched, no claimant yet
|
||||
CLAIMED, // in flight with a claimant (pre-started or direct)
|
||||
REPORT_PENDING // completed and claimed: deliver on the next flush
|
||||
};
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
// Wired by codegen before setup and invariant for the device lifetime.
|
||||
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
|
||||
|
||||
// ---- esp32_ble_tracker::ESPBTClient ----
|
||||
explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {}
|
||||
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
|
||||
@@ -60,10 +43,26 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
void on_scan_end() override {}
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
|
||||
|
||||
void schedule_disconnect() { this->want_disconnect_ = true; }
|
||||
|
||||
protected:
|
||||
BluedroidGattClient *engine_;
|
||||
};
|
||||
|
||||
class BluedroidGattClient final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
// Wired by codegen before setup and invariant for the device lifetime.
|
||||
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
|
||||
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
|
||||
|
||||
// ---- ble_device_base::BLEGattConnection contract ----
|
||||
int connect(uint64_t address, uint8_t addr_type);
|
||||
int gatt_disconnect();
|
||||
bool cancel_gatt_disconnect();
|
||||
int disconnect();
|
||||
int discover_services();
|
||||
int read_characteristic(uint16_t handle);
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
@@ -72,14 +71,20 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
// Contract stub: the proxy streams in place; the on-demand materializer
|
||||
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
|
||||
// this stub gets an empty table indistinguishable from a service-less
|
||||
// peer - do not ship one against this backend before the materializer.
|
||||
// Materialized on demand from Bluedroid's cached database for direct
|
||||
// consumers that resolve handles by UUID. The streaming consumer (the
|
||||
// proxy wrapper) never calls this - it uses stream_service_batch - so the
|
||||
// materializer only compiles when codegen declares a direct consumer
|
||||
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
|
||||
// footprint; a direct consumer's peak is bounded by its one known device.
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
#else
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
/// In-place service streamer (the proxy wrapper detects and prefers it):
|
||||
/// builds one api response batch directly from Bluedroid's cached database,
|
||||
/// so the streaming peak is the response itself - the old esp32 model.
|
||||
@@ -89,52 +94,71 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
|
||||
protected:
|
||||
friend class BluedroidTrackerShim;
|
||||
|
||||
esp32_ble_tracker::ClientState state_() const { return this->shim_.state(); }
|
||||
void set_state_(esp32_ble_tracker::ClientState st) { this->shim_.set_state(st); }
|
||||
bool check_addr_(const esp_bd_addr_t &addr) const;
|
||||
void tracker_connect_();
|
||||
bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param);
|
||||
void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
|
||||
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
|
||||
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
|
||||
int handle_search_cmpl_(esp_gatt_status_t status);
|
||||
void deliver_pending_search_();
|
||||
void handle_search_cmpl_();
|
||||
void unconditional_disconnect_();
|
||||
void set_idle_();
|
||||
void set_disconnecting_();
|
||||
void report_connection_state_(bool connected, int error);
|
||||
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
|
||||
const char *param_type);
|
||||
int check_and_log_error_(const char *operation, esp_err_t err);
|
||||
void log_gattc_warning_(const char *operation, int code);
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
|
||||
bool build_service_table_();
|
||||
void free_service_table_();
|
||||
ble_device_base::GattServiceTable table_view_() const;
|
||||
#endif
|
||||
|
||||
// Group 1: pointers / composed objects
|
||||
BluedroidTrackerShim shim_{this};
|
||||
ble_device_base::GattClientListener *listener_{nullptr};
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
// One exact-size block carved into the table's three arrays; owned here,
|
||||
// freed by release_services(). Null when no table is materialized. The
|
||||
// GattServiceTable view is rebuilt from this pointer and the counts on
|
||||
// each (cold) get_service_table() call instead of being cached.
|
||||
uint8_t *table_storage_{nullptr};
|
||||
#endif
|
||||
// Group 2: 4-byte types
|
||||
int gattc_if_{ESP_GATT_IF_NONE};
|
||||
uint32_t disconnecting_started_{0};
|
||||
|
||||
// Group 3: arrays
|
||||
esp_bd_addr_t remote_bda_{};
|
||||
|
||||
// Group 4: 2-byte types
|
||||
uint16_t conn_id_{UNSET_CONN_ID};
|
||||
uint16_t conn_id_{0xFFFF};
|
||||
uint16_t mtu_{23};
|
||||
uint16_t service_total_{0};
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
// Filled element counts of the materialized table (0 when none).
|
||||
uint16_t table_char_total_{0};
|
||||
uint16_t table_desc_total_{0};
|
||||
#endif
|
||||
|
||||
// Group 5: 1-byte types
|
||||
esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes
|
||||
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
|
||||
uint8_t remote_addr_type_{0};
|
||||
esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE};
|
||||
uint8_t connection_index_{0};
|
||||
// Terminates an in-flight stream (never send a partial list as authoritative)
|
||||
// and marks a cleaned cache unsafe to walk (Bluedroid asserts).
|
||||
bool services_released_ : 1 {false};
|
||||
uint8_t connection_index_;
|
||||
// Set only when release_services() cleans the stack's GATT cache, which no
|
||||
// walk may then touch (Bluedroid asserts rather than erroring).
|
||||
bool services_released_{false};
|
||||
// The connected report waits for the MTU exchange; OPEN_EVT alone would
|
||||
// hand HA the default 23.
|
||||
bool seen_mtu_ : 1 {false};
|
||||
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
|
||||
bool mtu_failed_ : 1 {false};
|
||||
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
|
||||
// completes from it. Reset by set_idle_().
|
||||
static_assert(static_cast<uint8_t>(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow");
|
||||
SearchState search_state_ : 4 {SearchState::NONE};
|
||||
// esp_gatt_status_t of the completed search, held until claimed.
|
||||
uint8_t search_status_{0};
|
||||
bool seen_mtu_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -28,8 +28,7 @@ class StubGattBackend {
|
||||
public:
|
||||
void set_listener(ble_device_base::GattClientListener *listener) {}
|
||||
int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
|
||||
int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
|
||||
bool cancel_gatt_disconnect() { return false; }
|
||||
int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
|
||||
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
|
||||
int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The proxy's per-slot connection wrapper, shared by every platform.
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -21,16 +21,16 @@ void BluetoothConnection::set_address(uint64_t address) {
|
||||
this->address_str_[0] = '\0';
|
||||
return;
|
||||
}
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
uint8_t mac[6];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
format_mac_addr_upper(mac, this->address_str_);
|
||||
}
|
||||
|
||||
void BluetoothConnection::initiate_connection(uint8_t address_type) {
|
||||
// No connect timeout here: the API client's own timeout or the api-gone
|
||||
// sweep drives disconnect().
|
||||
void BluetoothConnection::start_connect_() {
|
||||
// No connect timeout here: the API client's own timeout or
|
||||
// the api-gone sweep drives disconnect().
|
||||
this->state_ = ClientState::CONNECTING;
|
||||
int err = this->backend_->connect(this->address_, address_type);
|
||||
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err);
|
||||
this->reset_connection_(err);
|
||||
@@ -38,21 +38,40 @@ void BluetoothConnection::initiate_connection(uint8_t address_type) {
|
||||
}
|
||||
|
||||
void BluetoothConnection::disconnect() {
|
||||
// Idempotent: the proxy's teardown loop calls this every 100 ms while the
|
||||
// API subscriber is gone, and a repeat call reaching the backend would
|
||||
// re-arm its teardown timer so the safety timeout never fires.
|
||||
// Idempotent: the proxy's teardown loop calls this
|
||||
// every 100 ms while the API subscriber is gone, and a repeat call must not
|
||||
// reach the backend (whose busy error would free the slot mid-teardown).
|
||||
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
|
||||
return;
|
||||
}
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
// Nonzero means nothing to tear down (both backends): free the slot.
|
||||
// Accepted teardowns always reach a terminal report.
|
||||
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err);
|
||||
int err = this->backend_->disconnect();
|
||||
if (err == GATT_NOT_CONNECTED) {
|
||||
// Backend already idle: free the slot so the client is not stuck.
|
||||
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_);
|
||||
this->reset_connection_(err);
|
||||
return;
|
||||
}
|
||||
if (err != 0) {
|
||||
// Transient refusal: stay DISCONNECTING and let the safety timeout
|
||||
// arbitrate rather than freeing a slot whose teardown is unresolved.
|
||||
// Latch the refusal unless a GATT cause is already recorded (first wins).
|
||||
ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
|
||||
if (this->pending_error_ == 0) {
|
||||
this->pending_error_ = err;
|
||||
}
|
||||
}
|
||||
this->state_ = ClientState::DISCONNECTING;
|
||||
this->disconnecting_started_ = millis();
|
||||
}
|
||||
|
||||
void BluetoothConnection::check_disconnect_timeout_() {
|
||||
// Safety net: if the backend's disconnect completion is lost (or a refusal
|
||||
// left the teardown unresolved), force the slot free instead of leaking it.
|
||||
// The caller already gates on DISCONNECTING.
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
|
||||
this->reset_connection_(GATT_NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_pairing_result(int status) {
|
||||
@@ -83,18 +102,26 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
if (connected && this->address_ == 0) {
|
||||
// Late completion for a slot that was already freed: nothing to report,
|
||||
// and the api-gone sweep or a new reservation owns the slot now.
|
||||
// Return ignored: nonzero just means the backend was already idle, and
|
||||
// re-arming a freed slot could clobber a new reservation.
|
||||
this->backend_->gatt_disconnect();
|
||||
int err = this->backend_->disconnect();
|
||||
if (err != 0 && err != GATT_NOT_CONNECTED) {
|
||||
// Log only: re-arming a freed slot could clobber a new reservation.
|
||||
ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (connected && this->state_ == ClientState::DISCONNECTING) {
|
||||
// The link came up after a disconnect request won the race; finish the
|
||||
// teardown instead of reporting a connection the client no longer wants.
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
int err = this->backend_->disconnect();
|
||||
// Fresh teardown attempt: give it the full safety window.
|
||||
this->disconnecting_started_ = millis();
|
||||
if (err == GATT_NOT_CONNECTED) {
|
||||
// Nothing left to tear down after all.
|
||||
this->reset_connection_(err);
|
||||
} else if (err != 0) {
|
||||
// Transient refusal while the link is up: keep DISCONNECTING and let
|
||||
// the safety timeout arbitrate (same policy as disconnect()).
|
||||
ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -103,10 +130,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
|
||||
// The API client has the services cached; never discover them. No
|
||||
// discovery phase needs the fast interval, so settle straight into the
|
||||
// shared steady-state parameters. Both backends already open cached
|
||||
// connections with these values (esp32 prefer-params, rp2 initiating
|
||||
// params), so this request is normally redundant - kept as a backstop
|
||||
// in case the initial parameters were negotiated away.
|
||||
// shared steady-state parameters. On esp32 the backend already set the
|
||||
// same values as prefer-params before opening, so this request is
|
||||
// usually redundant there - kept because rp2 has no prefer-params and
|
||||
// the explicit update is its only path to the steady-state interval.
|
||||
this->state_ = ClientState::ESTABLISHED;
|
||||
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
|
||||
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
|
||||
@@ -121,13 +148,14 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
return;
|
||||
}
|
||||
// V3_WITHOUT_CACHE: discover services first — the connected response is
|
||||
// sent when discovery completes (MTU + services before the response).
|
||||
// sent when discovery completes, mirroring the esp32 flow (MTU + services
|
||||
// before the response).
|
||||
this->state_ = ClientState::CONNECTED;
|
||||
int err = this->backend_->discover_services();
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err);
|
||||
// Latch the real cause for the disconnect report.
|
||||
this->latch_pending_error_(err);
|
||||
this->pending_error_ = err;
|
||||
this->disconnect();
|
||||
}
|
||||
return;
|
||||
@@ -146,7 +174,7 @@ void BluetoothConnection::on_service_discovery_done(int error) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error);
|
||||
// Carry the GATT error into the disconnection report so the client sees
|
||||
// the real cause instead of a generic HCI reason.
|
||||
this->latch_pending_error_(error);
|
||||
this->pending_error_ = error;
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
@@ -299,39 +327,25 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval,
|
||||
|
||||
// ---- Service streaming ----
|
||||
|
||||
void BluetoothConnection::send_services_done_() {
|
||||
if (this->proxy_->send_gatt_services_done(this->address_)) {
|
||||
// Sent, or subscriber gone (park silently; its timeout arbitrates).
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
return;
|
||||
}
|
||||
if (this->send_service_ != SERVICES_DONE_PENDING) {
|
||||
// Warn on the transition only; retries stay silent.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_);
|
||||
this->services_done_retries_ = 0;
|
||||
this->send_service_ = SERVICES_DONE_PENDING;
|
||||
} else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
|
||||
// Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_);
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::send_service_for_discovery_() {
|
||||
auto table = this->backend_->get_service_table();
|
||||
if (this->send_service_ >= table.service_count) {
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->proxy_->send_gatt_services_done(this->address_);
|
||||
this->backend_->release_services();
|
||||
this->send_services_done_();
|
||||
return;
|
||||
}
|
||||
|
||||
// The subscriber vanished mid-stream; the api-gone sweep tears the
|
||||
// connection down anyway.
|
||||
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
|
||||
// sending services-done (esp32 parity — a resubscribing client gets
|
||||
// silence and its 30 s timeout, never an authoritative partial list) and
|
||||
// free the table; the api-gone sweep tears the connection down anyway.
|
||||
auto *api_conn = this->proxy_->get_api_connection();
|
||||
if (api_conn == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_,
|
||||
this->address_str_);
|
||||
this->park_service_stream_();
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,7 +383,8 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream",
|
||||
this->connection_index_, this->address_str_, this->send_service_);
|
||||
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
if (char_count > 0) {
|
||||
@@ -385,7 +400,8 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream",
|
||||
this->connection_index_, this->address_str_, this->send_service_);
|
||||
this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY);
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
if (desc_count == 0) {
|
||||
@@ -420,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
@@ -10,7 +10,7 @@
|
||||
// The wrapper exists to serve the proxy's API surface; direct consumers
|
||||
// drive the backend themselves, so backend-only builds compile this header
|
||||
// empty.
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
#include "bluetooth_connection_gatt_backend.h"
|
||||
@@ -41,28 +41,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
conn_err_t notify_characteristic(uint16_t handle, bool enable);
|
||||
conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
|
||||
/// Streamer abort: latch the GATT cause, park the cursor, tear down.
|
||||
void abort_service_stream(conn_err_t err) {
|
||||
this->latch_pending_error_(err);
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->disconnect();
|
||||
/// Start connecting: record the API address type (BLE_ADDR_TYPE_* code
|
||||
/// space) and open the connection through the backend. Failures report
|
||||
/// through the same reset path a failed open takes on esp32.
|
||||
void initiate_connection(uint8_t address_type) {
|
||||
this->remote_addr_type_ = address_type;
|
||||
this->start_connect_();
|
||||
}
|
||||
|
||||
/// Start connecting with the API address type (BLE_ADDR_TYPE_* code
|
||||
/// space). Failures report through the same reset path a failed open
|
||||
/// takes.
|
||||
void initiate_connection(uint8_t address_type);
|
||||
void disconnect();
|
||||
/// A connect request racing a scheduled teardown: true when the backend
|
||||
/// had not started closing - the in-flight open resumes and reports
|
||||
/// connected. False once the teardown owns the link.
|
||||
bool cancel_teardown() {
|
||||
if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) {
|
||||
this->state_ = ClientState::CONNECTING;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool is_paired() const { return this->paired_; }
|
||||
void set_unpaired() { this->paired_ = false; }
|
||||
conn_err_t pair() { return this->backend_->pair(); }
|
||||
@@ -77,9 +63,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
|
||||
void set_connection_type(ConnectionType ct) {
|
||||
this->connection_type_ = ct;
|
||||
// Both backends branch on the type before connecting (bluedroid picks
|
||||
// prefer-params and the with-cache report at OPEN_EVT; rp2 picks the
|
||||
// initiating parameters), so this must be set before the connect starts.
|
||||
// The bluedroid backend branches on the type itself (prefer-params and
|
||||
// the with-cache report at OPEN_EVT); the others ignore it.
|
||||
this->backend_->set_connection_type(ct);
|
||||
}
|
||||
// Latched at discovery completion rather than read from the backend table:
|
||||
@@ -88,12 +73,18 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
// an authoritative empty database).
|
||||
bool has_gatt_services() const { return this->services_discovered_; }
|
||||
|
||||
/// Stream any pending service-discovery batch (proxy loop; the backend
|
||||
/// owns the disconnect safety timer).
|
||||
/// Stream any pending service-discovery batch and police the disconnect
|
||||
/// safety timeout. Called from the proxy's loop — the wrapper has no
|
||||
/// Component loop of its own.
|
||||
void process_pending_services() {
|
||||
if (this->send_service_ >= 0) {
|
||||
this->stream_pending_(this->backend_);
|
||||
}
|
||||
// Inline state gate: this runs per loop iteration for every slot, and the
|
||||
// 10 s safety net only matters while DISCONNECTING.
|
||||
if (this->state_ == ClientState::DISCONNECTING) {
|
||||
this->check_disconnect_timeout_();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backend event listener (called directly by the backend, main loop) ----
|
||||
@@ -110,12 +101,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
// The Bluedroid backend streams services in place from its stack cache.
|
||||
friend class BluedroidGattClient;
|
||||
|
||||
/// First cause wins: a later, less specific error must not overwrite it.
|
||||
void latch_pending_error_(conn_err_t err) {
|
||||
if (this->pending_error_ == 0) {
|
||||
this->pending_error_ = err;
|
||||
}
|
||||
}
|
||||
void start_connect_();
|
||||
// A backend providing its own streamer (see the contract doc) builds the
|
||||
// response in place from its stack cache; the rest use the table streamer.
|
||||
// Template so the discarded branch is not odr-checked against backends
|
||||
@@ -127,23 +113,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
this->send_service_for_discovery_();
|
||||
}
|
||||
}
|
||||
/// Park the stream without services-done and free any held table: an
|
||||
/// interrupted stream must never be declared complete (the client's
|
||||
/// timeout arbitrates), and an owed done is dropped with it.
|
||||
void park_service_stream_() {
|
||||
if (this->send_service_ >= 0) {
|
||||
this->backend_->release_services();
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
} else if (this->send_service_ == SERVICES_DONE_PENDING) {
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
}
|
||||
}
|
||||
void send_service_for_discovery_();
|
||||
/// Send services-done and settle the cursor: DONE when it lands (or no
|
||||
/// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain
|
||||
/// retries). Callers release the table first; the message needs only the
|
||||
/// address.
|
||||
void send_services_done_();
|
||||
void check_disconnect_timeout_();
|
||||
void reset_connection_(conn_err_t reason);
|
||||
conn_err_t check_connected_op_(const char *action, const char *type) const;
|
||||
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
|
||||
@@ -155,30 +126,25 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
|
||||
// Group 2: 2-byte types
|
||||
int16_t send_service_{INIT_SENDING_SERVICES};
|
||||
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
|
||||
uint16_t mtu_{23};
|
||||
|
||||
// Group 3: 8-byte and 4-byte types
|
||||
uint64_t address_{0};
|
||||
uint32_t disconnecting_started_{0};
|
||||
conn_err_t pending_error_{0};
|
||||
|
||||
// Group 4: Arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
|
||||
// Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48.
|
||||
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
|
||||
static_assert(static_cast<uint8_t>(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2),
|
||||
"connection_type_ bitfield too narrow");
|
||||
// Ordered so neither byte's fields straddle a storage unit: 3+5 and
|
||||
// 4+2+1+1 fill the two tail bytes exactly.
|
||||
ClientState state_ : 3 {ClientState::IDLE};
|
||||
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
|
||||
uint8_t services_done_retries_ : 5 {0};
|
||||
uint8_t connection_index_ : 4 {0};
|
||||
ConnectionType connection_type_ : 2 {ConnectionType::V1};
|
||||
bool paired_ : 1 {false};
|
||||
bool services_discovered_ : 1 {false};
|
||||
// Group 5: 1-byte types
|
||||
ClientState state_{ClientState::IDLE};
|
||||
bool paired_{false};
|
||||
ConnectionType connection_type_{ConnectionType::V1};
|
||||
uint8_t remote_addr_type_{0};
|
||||
uint8_t connection_index_{0};
|
||||
bool services_discovered_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
@@ -5,7 +5,6 @@
|
||||
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <BluetoothLock.h>
|
||||
@@ -26,13 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
// and keeps the scan inhibited, so the engine cancels after 20 s. The
|
||||
// disconnect timeout mirrors the esp32 CLOSE_EVT safety net.
|
||||
static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000;
|
||||
// Budget after a cancel is in flight: its completion normally lands within
|
||||
// tens of ms, and while the engine waits it pins the stack-wide connect slot,
|
||||
// so a lost completion must cost seconds, not another full connect budget.
|
||||
static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000;
|
||||
// Pending engines re-attempt gap_connect on this cadence instead of every
|
||||
// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock.
|
||||
static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50;
|
||||
// Can-send windows normally open within a connection interval (tens of ms).
|
||||
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
|
||||
|
||||
@@ -61,7 +53,6 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {};
|
||||
uint8_t RP2GattClient::instance_count = 0;
|
||||
btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {};
|
||||
btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {};
|
||||
RP2GattClient *RP2GattClient::connect_owner = nullptr;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) {
|
||||
@@ -92,7 +83,6 @@ void RP2GattClient::setup() {
|
||||
// One locked section: the slot store lands before the count bump, and a
|
||||
// live HCI handler (N > 1 builds) cannot read a half-written registry.
|
||||
BluetoothLock lock;
|
||||
this->engine_index_ = instance_count;
|
||||
instances[instance_count] = this;
|
||||
instance_count++;
|
||||
// One HCI event handler for all engine instances (BTstack supports
|
||||
@@ -105,24 +95,9 @@ void RP2GattClient::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
|
||||
// esp32 parity (its tracker disconnects every client at OTA start): free
|
||||
// the shared radio for the transfer. No restore needed; the client
|
||||
// reconnects, and on success the device reboots anyway.
|
||||
if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) {
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); }
|
||||
@@ -148,56 +123,34 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *
|
||||
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) {
|
||||
break;
|
||||
}
|
||||
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
|
||||
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
// Route by ownership, not address: gap_connect refuses a new
|
||||
// create-connection until the previous completion is processed, so the
|
||||
// event belongs to the owner by construction. Cancel completions carry
|
||||
// a zeroed peer address on this controller, so an address match would
|
||||
// drop them and pin the owner until its backstop.
|
||||
RP2GattClient *inst = connect_owner;
|
||||
static constexpr bd_addr_t ZERO_ADDR = {};
|
||||
if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 &&
|
||||
memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) {
|
||||
// Addressed completion for a peer the owner is not connecting to: a
|
||||
// success delayed past a cancel and an ownership handoff (the cancel
|
||||
// idles the stack's request immediately) must not stamp the old
|
||||
// procedure's link onto the new owner. Zero-address (cancel)
|
||||
// completions need no such guard: BTstack only emits them while its
|
||||
// request state is idle, and a new owner re-arms that state when it
|
||||
// claims the token, so a stale cancel completion is swallowed by the
|
||||
// stack, never re-attributed. A successful stale link still needs
|
||||
// disposal (same hazard as the unowned branch below).
|
||||
if (status == 0) {
|
||||
gap_disconnect(con_handle);
|
||||
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
|
||||
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
|
||||
// Route to the engine that is waiting for this peer.
|
||||
for (uint8_t i = 0; i < instance_count; i++) {
|
||||
RP2GattClient *inst = instances[i];
|
||||
if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
connect_owner = nullptr;
|
||||
if (inst == nullptr) {
|
||||
if (status == 0) {
|
||||
// Nobody owns this late link (the owner escalated first): tear it
|
||||
// down here or the hci_connection_t leaks and the peer answers
|
||||
// DISALLOWED until reboot.
|
||||
gap_disconnect(con_handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (status == 0) {
|
||||
// Stamp the handle here in the BTstack context: a disconnection
|
||||
// racing the queued CONNECTED event arrives in this same context
|
||||
// and must route by handle (it carries no address).
|
||||
inst->con_handle_ = con_handle;
|
||||
}
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
}
|
||||
case HCI_EVENT_DISCONNECTION_COMPLETE: {
|
||||
// Routable even against a still-queued CONNECTED event: the handle is
|
||||
// stamped in this context at connection-complete time.
|
||||
RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet));
|
||||
hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet);
|
||||
RP2GattClient *inst = instance_for_con_handle(con_handle);
|
||||
if (inst == nullptr && instance_count == 1) {
|
||||
// The main loop may not have recorded the handle yet (the CONNECTED
|
||||
// event is still queued); with a single engine the connecting
|
||||
// instance is unambiguous, so route there to close the
|
||||
// accept-then-drop window. With multiple engines the event has no
|
||||
// address to match on, so it must be dropped instead of guessed.
|
||||
RP2GattClient *candidate = instances[0];
|
||||
if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) {
|
||||
inst = candidate;
|
||||
}
|
||||
}
|
||||
if (inst != nullptr) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
|
||||
}
|
||||
@@ -439,73 +392,44 @@ void RP2GattClient::loop() {
|
||||
if (dropped > 0) {
|
||||
// Control events must not be lost; the connection state is no longer
|
||||
// trustworthy — recover with a forced teardown.
|
||||
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
|
||||
this->gatt_disconnect();
|
||||
ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped);
|
||||
this->disconnect();
|
||||
}
|
||||
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
|
||||
if (notify_dropped > 0) {
|
||||
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
|
||||
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
|
||||
}
|
||||
|
||||
if (this->state_ == EngineState::CONNECT_PENDING) {
|
||||
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
uint32_t now = millis();
|
||||
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
|
||||
// Never reached the radio; nothing stack-side to cancel.
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_);
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
} else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) {
|
||||
this->connect_retry_ms_ = now;
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->fail_connection_(static_cast<uint8_t>(err));
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
uint32_t now = millis();
|
||||
bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID &&
|
||||
this->connect_cancel_attempted_;
|
||||
uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS;
|
||||
if (now - this->connect_started_ > budget) {
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_);
|
||||
bool link_up = this->state_ != EngineState::CONNECTING;
|
||||
bool cancel_sent = false;
|
||||
if (!link_up) {
|
||||
BluetoothLock lock;
|
||||
// Handle check under the lock: a success completion can stamp it in
|
||||
// the BTstack context right up to this point, and escalating past a
|
||||
// live link would orphan it (the queued CONNECTED event is dropped
|
||||
// by the state guard once fail_connection_ runs).
|
||||
link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID;
|
||||
if (!link_up && connect_owner == this) {
|
||||
// gap_connect_cancel is stack-global; only the engine whose
|
||||
// create-connection is in flight may issue it. First timeout:
|
||||
// cancel and give the completion a grace period. Second: the
|
||||
// completion was lost, re-issue the cancel in case the procedure
|
||||
// still runs (a no-op on an idle stack), then escalate.
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
if (!this->connect_cancel_attempted_) {
|
||||
this->connect_cancel_attempted_ = true;
|
||||
BluetoothLock lock;
|
||||
gap_connect_cancel();
|
||||
cancel_sent = !this->connect_cancel_attempted_;
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer
|
||||
// so a lost event escalates below instead of wedging here.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
// The cancel's completion never arrived: reclaim the slot and the
|
||||
// scan rather than cancelling forever.
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
this->connect_cancel_attempted_ = true;
|
||||
}
|
||||
if (link_up) {
|
||||
// The link is up (stamped mid-timeout or MTU exchange stalled): tear
|
||||
// it down properly so the controller frees its side; the
|
||||
// DISCONNECTING safety net below reclaims state if the disconnection
|
||||
// event is lost. Dropping engine state without gap_disconnect would
|
||||
// leak the live link and this engine's GATT slot for the rest of the
|
||||
// boot.
|
||||
this->gatt_disconnect();
|
||||
} else if (cancel_sent) {
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer so
|
||||
// a lost event escalates on the short cancel budget.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
// The link is up (MTU exchange stalled): tear it down properly so the
|
||||
// controller frees its side; the DISCONNECTING safety net below
|
||||
// reclaims state if the disconnection event is lost. Dropping engine
|
||||
// state without gap_disconnect would leak the live link and the
|
||||
// single GATT slot for the rest of the boot.
|
||||
this->disconnect();
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::DISCONNECTING) {
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
|
||||
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
|
||||
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
|
||||
@@ -521,7 +445,7 @@ void RP2GattClient::loop() {
|
||||
}
|
||||
}
|
||||
if (timed_out) {
|
||||
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_);
|
||||
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
|
||||
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
|
||||
}
|
||||
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
|
||||
@@ -542,7 +466,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
|
||||
case RP2GattEvent::MTU_EXCHANGED:
|
||||
if (this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
this->mtu_ = event.value;
|
||||
ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
|
||||
ESP_LOGD(TAG, "MTU %u", this->mtu_);
|
||||
this->state_ = EngineState::READY;
|
||||
// Scanning resumes and runs alongside the established connection.
|
||||
this->release_scan_inhibit_();
|
||||
@@ -590,7 +514,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
return;
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
|
||||
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
|
||||
this->fail_connection_(status);
|
||||
return;
|
||||
}
|
||||
@@ -614,7 +538,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
}
|
||||
this->con_handle_ = con_handle;
|
||||
this->state_ = EngineState::MTU_EXCHANGE;
|
||||
ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
|
||||
ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle);
|
||||
BluetoothLock lock;
|
||||
// One wildcard listener covers notifications/indications for every
|
||||
// characteristic on this connection; the CCCD writes come from the API
|
||||
@@ -639,24 +563,6 @@ void RP2GattClient::release_scan_inhibit_() {
|
||||
}
|
||||
|
||||
void RP2GattClient::fail_connection_(uint8_t reason) {
|
||||
{
|
||||
// Timeout escalation can fire with the completion event lost; release the
|
||||
// stack-wide connect slot so pending engines can proceed. Until the old
|
||||
// completion is processed, gap_connect answers any peer with DISALLOWED
|
||||
// (the request-level guard in hci.c); a cancel idles that request
|
||||
// immediately, and a late addressed completion from the old procedure is
|
||||
// then dropped by the owner-peer cross-check in the handler.
|
||||
BluetoothLock lock;
|
||||
if (connect_owner == this) {
|
||||
connect_owner = nullptr;
|
||||
}
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
// A success completion stamped the handle between the escalation
|
||||
// decision and this lock: tear the link down before cleanup wipes the
|
||||
// handle, or it leaks its pool block for the rest of the boot.
|
||||
gap_disconnect(this->con_handle_);
|
||||
}
|
||||
}
|
||||
this->cleanup_link_state_();
|
||||
this->release_scan_inhibit_();
|
||||
this->state_ = EngineState::IDLE;
|
||||
@@ -670,19 +576,14 @@ void RP2GattClient::cleanup_link_state_() {
|
||||
while ((stale = this->notify_queue_.pop()) != nullptr) {
|
||||
this->notify_pool_.release(stale);
|
||||
}
|
||||
// con_handle_ may be stamped in the BTstack context before the main loop
|
||||
// registers the listener, so a valid handle does not imply a registration;
|
||||
// stop_listening on an unregistered entry is a benign no-op. One lock
|
||||
// scope around check and reset so an IRQ stamp cannot land in between
|
||||
// (unreachable today — ownership is released before cleanup — but the
|
||||
// invariant lives three functions away).
|
||||
{
|
||||
// The wildcard listener is registered on the normal connect path right
|
||||
// after con_handle_ is recorded; the cancel branch tears down before
|
||||
// registering, where stop_listening on an unregistered entry is a no-op.
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
BluetoothLock lock;
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
this->notify_subscription_count_ = 0;
|
||||
this->cancel_requested_ = false;
|
||||
this->op_type_ = OpType::NONE;
|
||||
@@ -694,7 +595,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
|
||||
if (this->state_ == EngineState::IDLE) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
|
||||
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
|
||||
this->fail_connection_(reason);
|
||||
}
|
||||
|
||||
@@ -752,7 +653,7 @@ int RP2GattClient::discover_services() {
|
||||
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
|
||||
this->arena_ = allocator.allocate(1);
|
||||
if (this->arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
|
||||
ESP_LOGE(TAG, "Service table allocation failed");
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
new (this->arena_) ServiceArena();
|
||||
@@ -858,8 +759,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
|
||||
|
||||
void RP2GattClient::finish_discovery_(int error) {
|
||||
this->discovery_phase_ = DiscoveryPhase::NONE;
|
||||
ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_,
|
||||
error, this->service_count_, this->char_count_, this->desc_count_);
|
||||
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
|
||||
this->char_count_, this->desc_count_);
|
||||
if (error == 0 && this->truncated_) {
|
||||
// A partial table must not stream: V3 clients cache the database
|
||||
// permanently, so an incomplete one would be wrong forever.
|
||||
@@ -937,80 +838,30 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
this->parent_->inhibit_scan();
|
||||
this->connect_cancel_attempted_ = false;
|
||||
this->cancel_requested_ = false;
|
||||
// Bounds the queued wait; restarted when gap_connect is accepted so the
|
||||
// radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the
|
||||
// sum via a disconnect request).
|
||||
this->connect_started_ = millis();
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->release_scan_inhibit_();
|
||||
return err;
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL,
|
||||
0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status);
|
||||
this->release_scan_inhibit_();
|
||||
return status;
|
||||
}
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One outgoing LE create-connection exists stack-wide: issue it if no other
|
||||
// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry.
|
||||
// Returns nonzero only for hard failures (state untouched; caller cleans up).
|
||||
int RP2GattClient::try_gap_connect_() {
|
||||
// Unlocked peek: single core, aligned pointer; a stale value costs one loop
|
||||
// pass and the locked re-check below is authoritative. Keeps the per-loop
|
||||
// pending retry from taking BluetoothLock just to find the radio busy.
|
||||
if (connect_owner != nullptr) {
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
if (connect_owner != nullptr) {
|
||||
status = ERROR_CODE_COMMAND_DISALLOWED;
|
||||
} else {
|
||||
// esp32 parity: cached connections come up at MEDIUM already (nothing
|
||||
// consumes the fast interval without a discovery phase), so there is no
|
||||
// post-connect update procedure to race or silently lose; sustained
|
||||
// FAST intervals also starve WiFi on the shared CYW43 radio.
|
||||
// Without-cache runs FAST for discovery and steps down in
|
||||
// finish_discovery_.
|
||||
bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW,
|
||||
cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL,
|
||||
cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0,
|
||||
cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
if (status == 0) {
|
||||
connect_owner = this;
|
||||
// Still under the lock: a synthesized failure completion can fire in
|
||||
// the BTstack context the instant it releases, and completion routing
|
||||
// requires CONNECTING — set after the fact, the event is discarded
|
||||
// and the engine burns its whole budget waiting for it.
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (status == ERROR_CODE_COMMAND_DISALLOWED) {
|
||||
// Radio busy with another engine's connect; resolved from loop().
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
int RP2GattClient::gatt_disconnect() {
|
||||
int RP2GattClient::disconnect() {
|
||||
switch (this->state_) {
|
||||
case EngineState::IDLE:
|
||||
return GATT_ERR_NOT_CONNECTED;
|
||||
case EngineState::DISCONNECTING:
|
||||
return 0; // already on its way down
|
||||
case EngineState::CONNECT_PENDING:
|
||||
// Nothing issued stack-side; the invalid handle takes the refused
|
||||
// path below without touching the stack.
|
||||
break;
|
||||
case EngineState::CONNECTING: {
|
||||
if (this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
// The cancel can lose the race against a successful connection
|
||||
@@ -1019,18 +870,9 @@ int RP2GattClient::gatt_disconnect() {
|
||||
// attempt, so a lost completion escalates on the next timeout tick.
|
||||
this->cancel_requested_ = true;
|
||||
this->connect_cancel_attempted_ = true;
|
||||
// Grace period for the cancel completion: the client's disconnect
|
||||
// often lands right at the engine's own deadline, and without the
|
||||
// restart the loop timeout fires first and reports before the
|
||||
// completion can finish the teardown cleanly.
|
||||
this->connect_started_ = millis();
|
||||
BluetoothLock lock;
|
||||
// Owner: the cancel completes as a failed connection-complete. Not
|
||||
// the owner (completion already resolved in the BTstack context): the
|
||||
// queued event drives the same teardown, nothing to cancel.
|
||||
if (connect_owner == this) {
|
||||
gap_connect_cancel();
|
||||
}
|
||||
gap_connect_cancel();
|
||||
// Completion arrives as a failed connection-complete event.
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -1038,23 +880,20 @@ int RP2GattClient::gatt_disconnect() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
{
|
||||
BluetoothLock lock;
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
// Refused (handle already gone) or never issued (CONNECT_PENDING):
|
||||
// complete via the event queue so the listener cannot re-enter
|
||||
// disconnect mid-call. BluetoothLock stops the IRQ producer, so this
|
||||
// main-loop push is SPSC-safe.
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
// Refused (handle already gone): complete via the event queue so the
|
||||
// listener cannot re-enter disconnect() mid-call. BluetoothLock stops
|
||||
// the IRQ producer, so this main-loop push is SPSC-safe.
|
||||
ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status);
|
||||
{
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
}
|
||||
}
|
||||
this->state_ = EngineState::DISCONNECTING;
|
||||
this->disconnecting_started_ = millis();
|
||||
@@ -1259,7 +1098,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_
|
||||
}
|
||||
|
||||
conn_err_t unpair_device(uint64_t address) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
uint8_t mac[6];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
bool found = false;
|
||||
BluetoothLock lock;
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#endif
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#include <array>
|
||||
@@ -75,13 +71,7 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
|
||||
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
|
||||
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
|
||||
|
||||
class RP2GattClient final : public Component,
|
||||
public Parented<rp2040_ble::RP2040BLE>
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -92,10 +82,7 @@ class RP2GattClient final : public Component,
|
||||
|
||||
// ---- ble_device_base::BLEGattConnection contract ----
|
||||
int connect(uint64_t address, uint8_t addr_type);
|
||||
int gatt_disconnect();
|
||||
// Teardown starts inside gatt_disconnect() on this backend; nothing is
|
||||
// ever scheduled, so there is nothing to cancel.
|
||||
bool cancel_gatt_disconnect() { return false; }
|
||||
int disconnect();
|
||||
int discover_services();
|
||||
int read_characteristic(uint16_t handle);
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
@@ -105,26 +92,18 @@ class RP2GattClient final : public Component,
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
// Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is
|
||||
// reserved for the discovery phase of uncached connects.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
// No connection-type branching on this backend.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) {}
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Drop the connection while an OTA runs (esp32 parity): an active link
|
||||
// competes with the transfer for the shared radio.
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
// Link/engine state. Discovery and GATT ops have their own cursors below —
|
||||
// the link stays READY while they run.
|
||||
enum class EngineState : uint8_t {
|
||||
IDLE,
|
||||
CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
DISCONNECTING,
|
||||
};
|
||||
|
||||
@@ -161,7 +140,6 @@ class RP2GattClient final : public Component,
|
||||
int issue_descriptor_query_(uint16_t char_index);
|
||||
void finish_discovery_(int error);
|
||||
void fail_connection_(uint8_t reason);
|
||||
int try_gap_connect_();
|
||||
void cleanup_link_state_();
|
||||
bool notify_subscribed_(uint16_t handle) const;
|
||||
static void can_write_no_rsp_trampoline(void *context);
|
||||
@@ -190,17 +168,13 @@ class RP2GattClient final : public Component,
|
||||
|
||||
// Group 3: 4-byte types
|
||||
uint32_t connect_started_{0};
|
||||
uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt
|
||||
uint32_t disconnecting_started_{0};
|
||||
uint32_t write_no_rsp_started_{0};
|
||||
// Unscoped C enum, so int-sized: lives with the 4-byte members to keep the
|
||||
// padding at the tail.
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
|
||||
// Group 4: 2-byte types (table counters written from the handler during
|
||||
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
|
||||
hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID};
|
||||
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
|
||||
uint16_t mtu_{23};
|
||||
uint16_t op_handle_{0};
|
||||
uint16_t op_len_{0};
|
||||
uint16_t service_count_{0};
|
||||
@@ -214,9 +188,8 @@ class RP2GattClient final : public Component,
|
||||
// listener's deliveries on this list (esp32 parity for enable=false).
|
||||
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
|
||||
uint8_t notify_subscription_count_{0};
|
||||
uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE};
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
EngineState state_{EngineState::IDLE};
|
||||
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
|
||||
OpType op_type_{OpType::NONE};
|
||||
@@ -238,12 +211,6 @@ class RP2GattClient final : public Component,
|
||||
static btstack_packet_callback_registration_t hci_event_registration;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static btstack_packet_callback_registration_t sm_event_registration;
|
||||
// The engine whose gap_connect is in flight: BTstack allows one outgoing LE
|
||||
// create-connection stack-wide, and gap_connect_cancel is global, so only
|
||||
// the owner may cancel. Written under BluetoothLock from the main loop,
|
||||
// cleared in the BTstack context when the procedure resolves.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RP2GattClient *connect_owner;
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -7,7 +7,6 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_ID,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2,
|
||||
@@ -48,13 +47,14 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
|
||||
|
||||
|
||||
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
|
||||
# supports active scanning — every current client (aioesphomeapi, bleak-esphome,
|
||||
# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
|
||||
# hub must not be admitted (it would be misdriven).
|
||||
# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT
|
||||
# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home
|
||||
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
|
||||
# proxy would be misdriven — bk72xx follows once the API carries a feature
|
||||
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
|
||||
# Coupled to bluetooth_connection: platforms with a GATT backend are also
|
||||
# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and
|
||||
# FILTER_SOURCE_FILES hub entry.
|
||||
_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2)
|
||||
# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry.
|
||||
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
|
||||
|
||||
DEPENDENCIES = ["api"]
|
||||
CODEOWNERS = ["@jesserockz", "@bdraco"]
|
||||
@@ -100,7 +100,7 @@ def _esp32_config_schema() -> cv.All:
|
||||
)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
|
||||
@@ -151,23 +151,23 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
|
||||
@functools.cache
|
||||
def _rp2_config_schema() -> cv.All:
|
||||
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
|
||||
GATT client backend in bluetooth_connection. Multi-slot builds replace the
|
||||
prebuilt library's one-client BTstack pools via linker --wrap, owned by
|
||||
rp2040_ble and requested when a second backend registers."""
|
||||
GATT client backend in bluetooth_connection. The slot limit comes from the
|
||||
prebuilt BTstack library (one connection today); the code is built for N."""
|
||||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||||
|
||||
def populate_connections(config: ConfigType) -> ConfigType:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# One wrapper + backend pair per slot, declared during validation so
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
|
||||
)(config)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
CONF_CONNECTIONS: [
|
||||
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
|
||||
],
|
||||
}
|
||||
|
||||
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
|
||||
@@ -185,8 +185,8 @@ def _rp2_config_schema() -> cv.All:
|
||||
min=1,
|
||||
max=max_conn,
|
||||
msg=f"rp2 supports at most {max_conn} connection slot(s); "
|
||||
"the BTstack pool overrides in rp2040_ble are sized "
|
||||
f"for {max_conn}",
|
||||
"the framework's BTstack library is built with "
|
||||
f"MAX_NR_GATT_CLIENTS {max_conn}",
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -203,13 +203,10 @@ def _rp2_config_schema() -> cv.All:
|
||||
async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
"""One wrapper + backend pair per slot; the platform-specific backend
|
||||
registration lives in bluetooth_connection.new_gatt_backend()."""
|
||||
connections = config.get(CONF_CONNECTIONS, [])
|
||||
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
|
||||
# this define whenever a proxy is present (zero on advertisement-only
|
||||
# hubs); sized here so it can never diverge from the loop below.
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections))
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
for connection_conf in config.get(CONF_CONNECTIONS, []):
|
||||
backend = await bluetooth_connection.new_gatt_backend(
|
||||
connection_conf, service_table=False
|
||||
)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
cg.add(connection.set_backend(backend))
|
||||
cg.add(var.register_connection(connection))
|
||||
@@ -266,15 +263,11 @@ def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
# Fail here with the actual reason. Without this gate the error surfaces
|
||||
# later as an unresolvable hub ID ("Are you missing a hub declaration?")
|
||||
# on platforms where no hub component can be declared.
|
||||
full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)])
|
||||
adv_only = ", ".join(
|
||||
sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS))
|
||||
)
|
||||
raise cv.Invalid(
|
||||
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
|
||||
"active-scan-capable BLE tracker hub is available for this "
|
||||
f"platform. It runs on {full} (full proxy) and {adv_only} "
|
||||
"(advertisement-only)."
|
||||
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
|
||||
"family (advertisement-only)."
|
||||
)
|
||||
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
|
||||
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
|
||||
@@ -373,6 +366,10 @@ async def _to_code_esp32(config: ConfigType) -> None:
|
||||
# registration into the proxy; the other hubs are polled instead.
|
||||
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
|
||||
|
||||
# Define max connections for protobuf fixed array
|
||||
connection_count = len(config.get(CONF_CONNECTIONS, []))
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
|
||||
|
||||
await _connections_to_code(var, config)
|
||||
|
||||
if config.get(CONF_CACHE_SERVICES):
|
||||
@@ -387,6 +384,12 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
|
||||
cg.add(var.set_ble_hub(hub))
|
||||
|
||||
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
|
||||
# this define whenever a proxy is present. Zero on advertisement-only hubs.
|
||||
# Sized from the instantiated connections so the define can never diverge
|
||||
# from the loop below (the define sizes fixed storage in the proxy).
|
||||
slots = len(config.get(CONF_CONNECTIONS, ()))
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
|
||||
await _connections_to_code(var, config)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
|
||||
|
||||
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return true; // Nobody subscribed: nothing owed
|
||||
return false;
|
||||
api::BluetoothScannerStateResponse resp;
|
||||
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
|
||||
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
|
||||
@@ -51,12 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
|
||||
return this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) {
|
||||
// False only on a refused frame, so the latch arms only when a retry is owed.
|
||||
this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state);
|
||||
}
|
||||
#else
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
void BluetoothProxy::send_polled_scanner_state_() {
|
||||
// One read feeds both the frame and the change detector; the detector only
|
||||
// advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
|
||||
@@ -67,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() {
|
||||
this->last_scan_running_ = running;
|
||||
}
|
||||
}
|
||||
#endif // USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
|
||||
@@ -83,7 +78,7 @@ void BluetoothProxy::setup() {
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// Only push hubs compile the slot; elsewhere loop() polls scan_running().
|
||||
this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) {
|
||||
static_cast<BluetoothProxy *>(self)->send_scanner_state_(state);
|
||||
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
|
||||
}});
|
||||
#endif
|
||||
}
|
||||
@@ -111,7 +106,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
|
||||
connection->address_str(), ble_device_base::client_state_to_string(state));
|
||||
@@ -120,7 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
|
||||
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
|
||||
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
|
||||
}
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
|
||||
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
|
||||
@@ -140,11 +135,11 @@ void BluetoothProxy::dump_config() {
|
||||
// Print configured facts. dump_config runs right after setup, before the
|
||||
// radio is up, so live scan state would always read "stopped" here — the
|
||||
// loop's BluetoothScannerStateResponse carries the changing value instead.
|
||||
char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_str[18];
|
||||
this->get_bluetooth_mac_address_pretty(mac_str);
|
||||
const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)";
|
||||
const char *scan_mode = this->configured_scan_active_ ? "active" : "passive";
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Active: %s\n"
|
||||
@@ -162,7 +157,7 @@ void BluetoothProxy::dump_config() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused.
|
||||
void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
@@ -195,84 +190,33 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
|
||||
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
|
||||
}
|
||||
|
||||
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
|
||||
// Match before free entry so one address never occupies two pool slots.
|
||||
PendingDisconnect *free_entry = nullptr;
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto &owed = this->pending_disconnections_[i];
|
||||
if (owed.matches(address)) {
|
||||
owed.set(address, error);
|
||||
return;
|
||||
}
|
||||
if (free_entry == nullptr && owed.empty()) {
|
||||
free_entry = &owed;
|
||||
}
|
||||
}
|
||||
if (free_entry != nullptr) {
|
||||
free_entry->set(address, error);
|
||||
return;
|
||||
}
|
||||
// Every entry is owed: evict the first so the newest loss is not silent too.
|
||||
ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full",
|
||||
(unsigned long long) this->pending_disconnections_[0].address());
|
||||
this->pending_disconnections_[0].set(address, error);
|
||||
}
|
||||
|
||||
void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
|
||||
// A reconnect supersedes the owed disconnect; a late resend would shadow
|
||||
// the new connection.
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
if (this->pending_disconnections_[i].matches(address)) {
|
||||
this->pending_disconnections_[i].clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
|
||||
if (!this->send_device_connection(connection->get_address(), false, 0, reason)) {
|
||||
// The client has no other way to learn of an unsolicited disconnect;
|
||||
// latch and let loop()'s paced drain deliver it. V by design: a louder
|
||||
// level would ride the same congested link this reports on.
|
||||
ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(),
|
||||
connection->address_str());
|
||||
this->latch_pending_disconnection_(connection->get_address(), reason);
|
||||
}
|
||||
this->send_device_connection(connection->get_address(), false, 0, reason);
|
||||
connection->set_address(0);
|
||||
connection->send_service_ = INIT_SENDING_SERVICES;
|
||||
this->send_connections_free();
|
||||
}
|
||||
|
||||
BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) {
|
||||
// Finish the scan before reserving: a free slot earlier in the array must
|
||||
// not win over a later slot that already holds the address, or one device
|
||||
// ends up on two slots with a second connection attempt racing the first.
|
||||
BluetoothConnection *free_slot = nullptr;
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto *connection = this->connections_[i];
|
||||
uint64_t conn_addr = connection->get_address();
|
||||
|
||||
if (conn_addr == address) {
|
||||
// A connect request supersedes an owed disconnect.
|
||||
if (reserve) {
|
||||
this->clear_pending_disconnection_(address);
|
||||
}
|
||||
if (conn_addr == address)
|
||||
return connection;
|
||||
|
||||
if (reserve && conn_addr == 0) {
|
||||
connection->send_service_ = INIT_SENDING_SERVICES;
|
||||
connection->set_address(address);
|
||||
// All connections must start at INIT
|
||||
// We only set the state if we allocate the connection
|
||||
// to avoid a race where multiple connection attempts
|
||||
// are made.
|
||||
connection->set_state(ClientState::INIT);
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (free_slot == nullptr && conn_addr == 0)
|
||||
free_slot = connection;
|
||||
}
|
||||
if (!reserve || free_slot == nullptr)
|
||||
return nullptr;
|
||||
this->clear_pending_disconnection_(address);
|
||||
free_slot->send_service_ = INIT_SENDING_SERVICES;
|
||||
free_slot->set_address(address);
|
||||
// All connections must start at INIT
|
||||
// We only set the state if we allocate the connection
|
||||
// to avoid a race where multiple connection attempts
|
||||
// are made.
|
||||
free_slot->set_state(ClientState::INIT);
|
||||
return free_slot;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
@@ -296,10 +240,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
this->send_device_connection(msg.address, true);
|
||||
this->send_connections_free();
|
||||
return;
|
||||
} else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect",
|
||||
connection->get_connection_index(), connection->address_str());
|
||||
return;
|
||||
} else if (connection->state() != ClientState::INIT) {
|
||||
// Covers CONNECTING too: a repeat request during a connect attempt is
|
||||
// ignored the same way.
|
||||
@@ -438,30 +378,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
|
||||
}
|
||||
if (!connection->has_gatt_services()) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
|
||||
// Through the retrying sender: a drop must not leave discovery hanging.
|
||||
// Re-entry does not depend on the cursor - this branch is gated on
|
||||
// has_gatt_services() alone, so no restore is needed.
|
||||
connection->send_services_done_();
|
||||
return;
|
||||
}
|
||||
if (connection->send_service_ > 0) {
|
||||
// A request mid-stream restarts from the top so the requester always
|
||||
// gets the full list. No duplicate risk: the client accumulates batches
|
||||
// per request, and a same-session re-request only happens after the
|
||||
// previous request timed out and discarded its partial list.
|
||||
ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(),
|
||||
connection->address_str());
|
||||
connection->send_service_ = 0;
|
||||
return;
|
||||
}
|
||||
if (connection->send_service_ == SERVICES_DONE_PENDING) {
|
||||
// A new request supersedes an owed done: the client accumulates batches
|
||||
// per request, so its fresh, empty accumulator plus a bare done would
|
||||
// cache as an empty database. The table is freed; the client's timeout
|
||||
// arbitrates.
|
||||
ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry",
|
||||
connection->get_connection_index(), connection->address_str());
|
||||
connection->send_service_ = DONE_SENDING_SERVICES;
|
||||
this->send_gatt_services_done(msg.address);
|
||||
return;
|
||||
}
|
||||
if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet
|
||||
@@ -509,7 +426,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -552,7 +469,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// Stream pending service-discovery batches every iteration; the streamer
|
||||
// handles a vanished API connection itself.
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
@@ -566,17 +483,8 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
if (this->connections_free_pending_ && this->api_connection_ != nullptr) {
|
||||
// Resend a dropped slot-state update, paced by the 100 ms gate so the
|
||||
// retry does not hammer the congestion it exists to survive; the
|
||||
// advertisement-only arm answers DISCONNECT requests with this message
|
||||
// too, so the drain compiles on every proxy build.
|
||||
this->connections_free_pending_ = false;
|
||||
this->send_connections_free(this->api_connection_);
|
||||
}
|
||||
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// The API subscriber is gone: tear down any connections it left behind
|
||||
// (disconnect() on an already-disconnecting slot is a no-op).
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
@@ -589,27 +497,7 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
// Paced retries of owed per-slot notifications; subscriber swaps clear
|
||||
// stale latches before this runs.
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto *connection = this->connections_[i];
|
||||
if (connection->send_service_ == SERVICES_DONE_PENDING) {
|
||||
connection->send_services_done_();
|
||||
}
|
||||
auto &owed = this->pending_disconnections_[i];
|
||||
if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) {
|
||||
owed.clear();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// Resend a dropped scanner-state push (see scanner_state_pending_).
|
||||
if (this->scanner_state_pending_) {
|
||||
this->send_scanner_state_(this->hub_->get_scanner_state());
|
||||
}
|
||||
#else
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// This hub doesn't push scanner-state transitions; poll and report on
|
||||
// change. A hub gaining push emits the define and drops this poll.
|
||||
if (this->hub_->scan_running() != this->last_scan_running_) {
|
||||
@@ -620,7 +508,7 @@ void BluetoothProxy::loop() {
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
|
||||
#ifndef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
// Advertisement-only proxy. GATT client connections are excluded at compile
|
||||
// time (no connection backend on this platform, or active: false), so every
|
||||
@@ -692,38 +580,25 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // !BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (api_connection != this->api_connection_) {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
// A previous subscriber still holds the slot. This is almost always a
|
||||
// stale connection from a client that dropped without a clean disconnect
|
||||
// and has not yet hit the keepalive timeout; rejecting the new
|
||||
// subscriber would silently starve it of advertisements until it
|
||||
// reconnects, so the newest subscriber wins instead.
|
||||
char old_peername[socket::SOCKADDR_STR_LEN];
|
||||
char new_peername[socket::SOCKADDR_STR_LEN];
|
||||
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
|
||||
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
|
||||
this->api_connection_->get_peername_to(old_peername));
|
||||
}
|
||||
// Stale retry latches belong to the previous subscriber's session; a
|
||||
// re-subscribe by the current one keeps what it is still owed.
|
||||
this->connections_free_pending_ = false;
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
// Neither a partial stream's tail nor an owed done belongs to the new
|
||||
// session; silence (the client's timeout) arbitrates.
|
||||
this->connections_[i]->park_service_stream_();
|
||||
}
|
||||
this->pending_disconnections_.fill({});
|
||||
#endif
|
||||
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
|
||||
// A previous subscriber still holds the slot. This is almost always a stale
|
||||
// connection from a client that dropped without a clean disconnect and has
|
||||
// not yet hit the keepalive timeout; rejecting the new subscriber would
|
||||
// silently starve it of advertisements until it reconnects, so the newest
|
||||
// subscriber wins instead.
|
||||
char old_peername[socket::SOCKADDR_STR_LEN];
|
||||
char new_peername[socket::SOCKADDR_STR_LEN];
|
||||
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
|
||||
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
|
||||
this->api_connection_->get_peername_to(old_peername));
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// get_scanner_state() is part of the push-hub surface (see BLEHubContract).
|
||||
this->send_scanner_state_(this->hub_->get_scanner_state());
|
||||
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
|
||||
#else
|
||||
this->send_polled_scanner_state_();
|
||||
#endif
|
||||
@@ -735,12 +610,18 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->connections_free_pending_ = false;
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
this->scanner_state_pending_ = false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDeviceConnectionResponse call;
|
||||
call.address = address;
|
||||
call.connected = connected;
|
||||
call.mtu = mtu;
|
||||
call.error = error;
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
void BluetoothProxy::send_connections_free() {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
this->send_connections_free(this->api_connection_);
|
||||
@@ -748,32 +629,15 @@ void BluetoothProxy::send_connections_free() {
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) {
|
||||
// Latch only for the current subscriber: loop() resends to api_connection_.
|
||||
if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) {
|
||||
// V like the api layer's own buffer-full log: a D would ride the same
|
||||
// full connection.
|
||||
ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full");
|
||||
this->connections_free_pending_ = true;
|
||||
}
|
||||
api_connection->send_message(this->connections_free_response_);
|
||||
}
|
||||
|
||||
bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
|
||||
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return true; // Nobody subscribed: nothing owed
|
||||
api::BluetoothDeviceConnectionResponse call;
|
||||
call.address = address;
|
||||
call.connected = connected;
|
||||
call.mtu = mtu;
|
||||
call.error = error;
|
||||
return this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
bool BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
|
||||
return;
|
||||
api::BluetoothGATTGetServicesDoneResponse call;
|
||||
call.address = address;
|
||||
return this->api_connection_->send_message(call);
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
|
||||
@@ -25,11 +24,9 @@ namespace esphome::bluetooth_proxy {
|
||||
using bluetooth_connection::CONN_OK;
|
||||
using bluetooth_connection::conn_err_t;
|
||||
using bluetooth_connection::GATT_NOT_CONNECTED;
|
||||
using bluetooth_connection::DONE_SENDING_SERVICES;
|
||||
using bluetooth_connection::INIT_SENDING_SERVICES;
|
||||
using bluetooth_connection::SERVICES_DONE_PENDING;
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
#endif
|
||||
@@ -60,45 +57,8 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
|
||||
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
|
||||
};
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
/// One owed freed-slot connected=false notification in a single word: the
|
||||
/// 48-bit address in the low bits, the sign-extending 16-bit reason on top.
|
||||
/// Every reason that reaches the pool (esp_gatt_status_t,
|
||||
/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t.
|
||||
class PendingDisconnect {
|
||||
public:
|
||||
constexpr void set(uint64_t address, conn_err_t error) {
|
||||
// Mask: the address originates from the client, and a stray high bit
|
||||
// must not corrupt the reason.
|
||||
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
|
||||
}
|
||||
constexpr void clear() { this->word_ = 0; }
|
||||
// Whole-word test: set() is only ever given a live (nonzero) address.
|
||||
constexpr bool empty() const { return this->word_ == 0; }
|
||||
// Masked like set(), so a stray high bit cannot defeat the pool lookups.
|
||||
constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); }
|
||||
constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; }
|
||||
constexpr conn_err_t error() const { return static_cast<int16_t>(this->word_ >> 48); }
|
||||
|
||||
private:
|
||||
static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL;
|
||||
uint64_t word_{0};
|
||||
};
|
||||
// Pin the packing at compile time: mask and sign round-trip for every
|
||||
// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit).
|
||||
constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
|
||||
PendingDisconnect p;
|
||||
p.set(address, error);
|
||||
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
|
||||
}
|
||||
static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
|
||||
static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
|
||||
static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
|
||||
static_assert(PendingDisconnect{}.empty());
|
||||
#endif
|
||||
|
||||
class BluetoothProxy final : public Component {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// Allow the connection to update connections_free_response_
|
||||
friend bluetooth_connection::BluetoothConnection;
|
||||
#endif
|
||||
@@ -109,9 +69,9 @@ class BluetoothProxy final : public Component {
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
void register_connection(BluetoothConnection *connection);
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
#ifndef USE_ESP32
|
||||
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
|
||||
// snapshots scan_active()/scan_running() and installs the raw callback, and
|
||||
@@ -137,14 +97,10 @@ class BluetoothProxy final : public Component {
|
||||
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
|
||||
}
|
||||
|
||||
/// False only when a subscriber refused the frame; true = delivered or
|
||||
/// nobody subscribed. Request-answer callers ignore the result (client
|
||||
/// timeouts cover those); only reset_connection_slot_ latches for retry.
|
||||
bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
|
||||
void send_connections_free();
|
||||
void send_connections_free(api::APIConnection *api_connection);
|
||||
/// Same convention as send_device_connection: false only on a refused frame.
|
||||
bool send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
|
||||
@@ -202,8 +158,8 @@ class BluetoothProxy final : public Component {
|
||||
return flags;
|
||||
}
|
||||
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> output) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE] = {};
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
|
||||
uint8_t mac[6] = {};
|
||||
this->hub_->get_adapter_mac(mac);
|
||||
// Unavailable -> empty string: some hubs (rp2040's BTstack) only learn
|
||||
// the address once the link layer is up, and report all-zero until then.
|
||||
@@ -216,9 +172,7 @@ class BluetoothProxy final : public Component {
|
||||
|
||||
protected:
|
||||
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
void send_scanner_state_(ble_device_base::ScannerState state);
|
||||
#else
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
void send_polled_scanner_state_();
|
||||
#endif
|
||||
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
|
||||
@@ -235,7 +189,7 @@ class BluetoothProxy final : public Component {
|
||||
}
|
||||
void log_advertisement_flush_();
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
|
||||
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
|
||||
void log_connection_info_(BluetoothConnection *connection, const char *message);
|
||||
@@ -243,7 +197,7 @@ class BluetoothProxy final : public Component {
|
||||
void log_not_connected_gatt_(const char *action, const char *type);
|
||||
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
/// Keep the pre-allocated connections-free message in step when a
|
||||
/// connection slot changes address (0 = free). Called from the connection
|
||||
/// classes' set_address().
|
||||
@@ -277,48 +231,32 @@ class BluetoothProxy final : public Component {
|
||||
/// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service
|
||||
/// discovery and retry, rather than being told a partial list is complete.
|
||||
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
|
||||
/// Drop any owed freed-slot notification for this address (client reconnected).
|
||||
void clear_pending_disconnection_(uint64_t address);
|
||||
/// Pool a refused freed-slot notification for the paced drain.
|
||||
void latch_pending_disconnection_(uint64_t address, conn_err_t error);
|
||||
#endif
|
||||
|
||||
// Memory optimized layout for 32-bit systems
|
||||
// Group 1: Pointers (4 bytes each, naturally aligned)
|
||||
api::APIConnection *api_connection_{nullptr};
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// Group 2: Fixed-size array of connection pointers
|
||||
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
|
||||
// Address-keyed pool of owed freed-slot notifications; loop() resends.
|
||||
// Proxy-only state, kept off BluetoothConnection; entries are not tied to
|
||||
// slot indices.
|
||||
std::array<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
|
||||
#endif
|
||||
ble_device_base::BLEHub *hub_{nullptr};
|
||||
// Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below
|
||||
// start on an even word, closing two alignment holes.
|
||||
uint32_t last_advertisement_flush_time_{0};
|
||||
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
|
||||
// Group 3: 4-byte types
|
||||
uint32_t last_advertisement_flush_time_{0};
|
||||
|
||||
// Pre-allocated response message - always ready to send
|
||||
api::BluetoothConnectionsFreeResponse connections_free_response_;
|
||||
|
||||
// Group 4: 1-byte types grouped together
|
||||
bool active_;
|
||||
// A dropped send (full TCP buffer) would leave the API client with a stale
|
||||
// slot state forever; the cached response is current by construction, so
|
||||
// retrying it from loop() is an idempotent resync.
|
||||
bool connections_free_pending_{false};
|
||||
uint8_t connection_count_{0};
|
||||
bool configured_scan_active_{false}; // Configured scan mode from YAML
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// A dropped push (full TX buffer) is re-queried from the hub and resent
|
||||
// from loop(); the hub's current state is idempotent by construction.
|
||||
bool scanner_state_pending_{false};
|
||||
#else
|
||||
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ from esphome.components.packages import validate_source_shorthand
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
|
||||
import esphome.final_validate as fv
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.yaml_util import dump
|
||||
|
||||
dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import")
|
||||
@@ -110,7 +109,6 @@ def import_config(
|
||||
if git_file.query and "full_config" in git_file.query:
|
||||
url = git_file.raw_url
|
||||
try:
|
||||
ensure_happy_eyeballs()
|
||||
req = requests.get(url, timeout=30)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
@@ -3286,19 +3286,9 @@ def copy_files():
|
||||
if str(path).startswith("http"):
|
||||
import requests
|
||||
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
|
||||
ensure_happy_eyeballs()
|
||||
|
||||
try:
|
||||
req = requests.get(path, timeout=30)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise EsphomeError(
|
||||
f"Could not download extra build file {path}: {e}"
|
||||
) from e
|
||||
CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
|
||||
CORE.relative_build_path(name).write_bytes(req.content)
|
||||
content = requests.get(path, timeout=30).content
|
||||
CORE.relative_build_path(name).write_bytes(content)
|
||||
else:
|
||||
copy_file_if_changed(path, CORE.relative_build_path(name))
|
||||
|
||||
|
||||
@@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat
|
||||
}
|
||||
#endif
|
||||
|
||||
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||
void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const {
|
||||
// The running stack owns the address (on hosted controllers it lives in
|
||||
// the remote chip's efuse); null before init becomes all-zero.
|
||||
const uint8_t *mac = esp_bt_dev_get_address();
|
||||
if (mac != nullptr) {
|
||||
memcpy(out, mac, MAC_ADDRESS_SIZE);
|
||||
memcpy(out, mac, 6);
|
||||
} else {
|
||||
memset(out, 0, MAC_ADDRESS_SIZE);
|
||||
memset(out, 0, 6);
|
||||
}
|
||||
}
|
||||
|
||||
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
|
||||
|
||||
void ESP32BLE::dump_config() {
|
||||
uint8_t mac_address[MAC_ADDRESS_SIZE];
|
||||
uint8_t mac_address[6];
|
||||
this->get_mac_msb_first(mac_address);
|
||||
if (mac_address_is_valid(mac_address)) {
|
||||
const char *io_capability_s;
|
||||
@@ -713,7 +713,7 @@ void ESP32BLE::dump_config() {
|
||||
break;
|
||||
}
|
||||
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
format_mac_addr_upper(mac_address, mac_s);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE:\n"
|
||||
|
||||
@@ -109,7 +109,7 @@ class ESP32BLE final : public Component {
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
/// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up.
|
||||
void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
|
||||
void get_mac_msb_first(uint8_t out[6]) const;
|
||||
float get_setup_priority() const override;
|
||||
void set_name(const char *name) { this->name_ = name; }
|
||||
|
||||
|
||||
@@ -128,7 +128,9 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
|
||||
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
|
||||
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
|
||||
# window/interval pairs that collapse to the same 0.625 ms unit count.
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
|
||||
"320ms", supports_active=True
|
||||
)
|
||||
|
||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||
# here for the components that import them from this module.
|
||||
|
||||
@@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component,
|
||||
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
|
||||
/* scan_mode_switch = */ false};
|
||||
}
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); }
|
||||
void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
|
||||
bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; }
|
||||
bool scan_active() { return this->scan_active_; }
|
||||
// The mode is driven through this tracker's own API (see get_capabilities);
|
||||
|
||||
@@ -132,7 +132,6 @@ ETHERNET_TYPES = {
|
||||
"W6300": EthernetType.ETHERNET_TYPE_W6300,
|
||||
"GENERIC": EthernetType.ETHERNET_TYPE_GENERIC,
|
||||
"YT8531": EthernetType.ETHERNET_TYPE_YT8531,
|
||||
"CH390": EthernetType.ETHERNET_TYPE_CH390,
|
||||
}
|
||||
|
||||
# PHY types that need compile-time defines for conditional compilation
|
||||
@@ -154,7 +153,6 @@ _PHY_TYPE_TO_DEFINE = {
|
||||
"W6300": "USE_ETHERNET_W6300",
|
||||
"GENERIC": "USE_ETHERNET_GENERIC",
|
||||
"YT8531": "USE_ETHERNET_YT8531",
|
||||
"CH390": "USE_ETHERNET_CH390",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,14 +176,13 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = {
|
||||
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"),
|
||||
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
|
||||
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
|
||||
"CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"),
|
||||
}
|
||||
|
||||
# These types are always external IDF components (never built-in to ESP-IDF)
|
||||
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"}
|
||||
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"}
|
||||
|
||||
# ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver)
|
||||
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"}
|
||||
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"}
|
||||
# RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole
|
||||
# RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the
|
||||
# comment above is about ESP-IDF driver coverage, not the RP2 platform.
|
||||
@@ -483,12 +480,6 @@ SPI_SCHEMA = _spi_schema()
|
||||
# of spec for it and makes the driver's CS hold time helper compute no hold
|
||||
SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
|
||||
|
||||
# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V,
|
||||
# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not.
|
||||
# CH390 datasheet v1.8, tables 9-4 and 9-5:
|
||||
# https://www.wch-ic.com/downloads/CH390DS1_PDF.html
|
||||
SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6))
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.typed_schema(
|
||||
{
|
||||
@@ -503,7 +494,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
"W5500": SPI_SCHEMA,
|
||||
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
|
||||
"DM9051": SPI_SCHEMA,
|
||||
"CH390": SPI_SCHEMA_CH390,
|
||||
"ENC28J60": SPI_SCHEMA_ENC28J60,
|
||||
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
|
||||
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
|
||||
@@ -639,11 +629,8 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
|
||||
add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True)
|
||||
# CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0
|
||||
# Types that are never built into IDF ship no Kconfig option at all
|
||||
if (
|
||||
idf_version() < cv.Version(6, 0, 0)
|
||||
and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS
|
||||
):
|
||||
# ENC28J60 was never built-in to IDF, so it has no Kconfig option
|
||||
if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60":
|
||||
add_idf_sdkconfig_option(
|
||||
f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True
|
||||
)
|
||||
|
||||
@@ -88,7 +88,6 @@ enum EthernetType : uint8_t {
|
||||
ETHERNET_TYPE_W6300,
|
||||
ETHERNET_TYPE_GENERIC,
|
||||
ETHERNET_TYPE_YT8531,
|
||||
ETHERNET_TYPE_CH390,
|
||||
};
|
||||
|
||||
struct ManualIP {
|
||||
|
||||
@@ -50,12 +50,6 @@
|
||||
#include "esp_eth_enc28j60.h"
|
||||
#endif
|
||||
|
||||
// CH390 headers exist on all IDF versions (always an external component)
|
||||
#ifdef USE_ETHERNET_CH390
|
||||
#include "esp_eth_mac_ch390.h"
|
||||
#include "esp_eth_phy_ch390.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET_SPI
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/spi_master.h>
|
||||
@@ -221,8 +215,6 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg);
|
||||
#elif defined(USE_ETHERNET_ENC28J60)
|
||||
eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg);
|
||||
#elif defined(USE_ETHERNET_CH390)
|
||||
eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg);
|
||||
#endif
|
||||
|
||||
#if defined(USE_ETHERNET_W5500)
|
||||
@@ -244,11 +236,6 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
// time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID")
|
||||
enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000);
|
||||
enc28j60_config.int_gpio_num = this->interrupt_pin_;
|
||||
#elif defined(USE_ETHERNET_CH390)
|
||||
ch390_config.int_gpio_num = this->interrupt_pin_;
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
ch390_config.poll_period_ms = this->polling_interval_;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
phy_config.phy_addr = this->phy_addr_spi_;
|
||||
@@ -373,12 +360,6 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
this->phy_ = esp_eth_phy_new_enc28j60(&phy_config);
|
||||
break;
|
||||
}
|
||||
#elif defined(USE_ETHERNET_CH390)
|
||||
case ETHERNET_TYPE_CH390: {
|
||||
mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config);
|
||||
this->phy_ = esp_eth_phy_new_ch390(&phy_config);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
default: {
|
||||
@@ -538,10 +519,6 @@ void EthernetComponent::dump_config() {
|
||||
case ETHERNET_TYPE_ENC28J60:
|
||||
eth_type = "ENC28J60";
|
||||
break;
|
||||
#elif defined(USE_ETHERNET_CH390)
|
||||
case ETHERNET_TYPE_CH390:
|
||||
eth_type = "CH390";
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_ETHERNET_OPENETH
|
||||
case ETHERNET_TYPE_OPENETH:
|
||||
|
||||
@@ -36,7 +36,6 @@ from esphome.const import (
|
||||
CONF_WEIGHT,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -320,7 +319,6 @@ def download_gfont(value):
|
||||
if not external_files.is_file_recent(path, value[CONF_REFRESH]):
|
||||
_LOGGER.debug("download_gfont: path=%s", path)
|
||||
try:
|
||||
ensure_happy_eyeballs()
|
||||
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import modbus
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@zweckj"]
|
||||
DEPENDENCIES = ["modbus"]
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_HOERMANN_HCP_ID = "hoermann_hcp_id"
|
||||
|
||||
hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp")
|
||||
HoermannHcp = hoermann_hcp_ns.class_(
|
||||
"HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice
|
||||
)
|
||||
|
||||
# The Hoermann UAP module answers on Modbus server address 2.
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)})
|
||||
.extend(cv.polling_component_schema("500ms"))
|
||||
.extend(modbus.modbus_device_schema(0x02, role="server"))
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device(
|
||||
"hoermann_hcp", role="server"
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await modbus.register_modbus_server_device(var, config)
|
||||
@@ -1,22 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import cover
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cover.cover_schema(HoermannHcpCover)
|
||||
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
var = await cover.new_cover(config, parent)
|
||||
await cg.register_component(var, config)
|
||||
@@ -1,87 +0,0 @@
|
||||
#include "hoermann_hcp_cover.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.cover";
|
||||
|
||||
cover::CoverTraits HoermannHcpCover::get_traits() {
|
||||
cover::CoverTraits traits;
|
||||
traits.set_supports_position(true);
|
||||
traits.set_supports_stop(true);
|
||||
traits.set_supports_toggle(true);
|
||||
return traits;
|
||||
}
|
||||
|
||||
void HoermannHcpCover::setup() {
|
||||
// Nothing is published before the bus controller is heard from, and the untouched position reads as fully
|
||||
// open, so flag the entity until the first contact clears it again.
|
||||
this->status_set_warning("waiting for the bus controller");
|
||||
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
|
||||
}
|
||||
|
||||
void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); }
|
||||
|
||||
void HoermannHcpCover::control(const cover::CoverCall &call) {
|
||||
bool accepted = true;
|
||||
if (call.get_stop())
|
||||
accepted &= this->parent_->stop_door();
|
||||
if (call.get_toggle().has_value())
|
||||
accepted &= this->parent_->impulse_door();
|
||||
if (const auto position = call.get_position())
|
||||
accepted &= this->parent_->set_position(*position);
|
||||
if (!accepted) {
|
||||
// The command never reached the door, so publish the unchanged state over the one the caller assumed.
|
||||
ESP_LOGW(TAG, "Command was not accepted by the door");
|
||||
this->publish_state(false);
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcpCover::update_from_state_() {
|
||||
if (!this->parent_->is_valid()) {
|
||||
this->status_set_warning();
|
||||
// The door can now move unheard, so drop the baseline a direction would be inferred from and stop
|
||||
// reporting motion instead of leaving the cover travelling until the controller returns.
|
||||
this->previous_position_ = NAN;
|
||||
if (this->current_operation != cover::COVER_OPERATION_IDLE) {
|
||||
this->current_operation = cover::COVER_OPERATION_IDLE;
|
||||
this->publish_state();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
|
||||
const auto previous_operation = this->current_operation;
|
||||
const float current_position = this->parent_->get_current_position();
|
||||
switch (this->parent_->get_door_state()) {
|
||||
case DoorState::OPENING:
|
||||
this->current_operation = cover::COVER_OPERATION_OPENING;
|
||||
break;
|
||||
case DoorState::CLOSING:
|
||||
this->current_operation = cover::COVER_OPERATION_CLOSING;
|
||||
break;
|
||||
case DoorState::MOVE_VENTING:
|
||||
case DoorState::MOVE_HALF:
|
||||
// These states carry no direction, so keep the current one until the position actually moves.
|
||||
if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) {
|
||||
this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING
|
||||
: cover::COVER_OPERATION_CLOSING;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this->current_operation = cover::COVER_OPERATION_IDLE;
|
||||
break;
|
||||
}
|
||||
this->previous_position_ = current_position;
|
||||
|
||||
// Compare against the position last published, which starts at COVER_OPEN rather than at zero.
|
||||
const bool changed = this->position != current_position || previous_operation != this->current_operation;
|
||||
this->position = current_position;
|
||||
if (changed) {
|
||||
// The bus reports the position on every broadcast, so nothing here is worth restoring from flash.
|
||||
this->publish_state(false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -1,27 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/cover/cover.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpCover : public cover::Cover, public Component {
|
||||
public:
|
||||
explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
cover::CoverTraits get_traits() override;
|
||||
void control(const cover::CoverCall &call) override;
|
||||
|
||||
protected:
|
||||
void update_from_state_();
|
||||
HoermannHcp *const parent_;
|
||||
// NAN until the first position is observed, so no direction is inferred from a baseline that never existed.
|
||||
float previous_position_{NAN};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -1,336 +0,0 @@
|
||||
#include "hoermann_hcp.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp";
|
||||
|
||||
// Hoermann HCP holding-register blocks.
|
||||
static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
|
||||
static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
|
||||
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
|
||||
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
|
||||
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
|
||||
|
||||
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
|
||||
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
|
||||
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
|
||||
|
||||
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
|
||||
// its low byte tells a plain stop from the vent position.
|
||||
struct DoorStateMapping {
|
||||
uint8_t code;
|
||||
DoorState state;
|
||||
};
|
||||
static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
|
||||
{0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF},
|
||||
{0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN},
|
||||
{0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN},
|
||||
};
|
||||
|
||||
// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
|
||||
// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
|
||||
static void push_zeros(modbus::RegisterValues ®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_) {
|
||||
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
}
|
||||
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
|
||||
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
|
||||
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
|
||||
this->clear_target_();
|
||||
}
|
||||
if (this->changed_) {
|
||||
this->changed_ = false;
|
||||
this->state_callback_.call();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Hoermann HCP bridge:\n"
|
||||
" Modbus server address: 0x%02X",
|
||||
this->get_address());
|
||||
}
|
||||
|
||||
modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®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]);
|
||||
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(0x0000);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Still inside the key-press window, so keep presenting 0x0000.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
// Enough time passed: present the "key released" values and clear the command.
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
this->next_command_ = nullptr;
|
||||
registers.push_back(command->released_value);
|
||||
registers.push_back(0x0000);
|
||||
}
|
||||
|
||||
void HoermannHcp::on_position_reg_(uint16_t value) {
|
||||
// Low byte: current position.
|
||||
const uint8_t position = static_cast<uint8_t>(value);
|
||||
if (this->position_raw_ == position)
|
||||
return;
|
||||
|
||||
this->position_raw_ = position;
|
||||
this->update_current_position_();
|
||||
// Until the door actually travels the way it was told to, its position says nothing about the target.
|
||||
if (!this->has_target_() || !this->target_started_)
|
||||
return;
|
||||
|
||||
// The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
|
||||
const bool reached = this->target_direction_ == DoorState::OPENING
|
||||
? this->current_position_ >= this->target_position_
|
||||
: this->current_position_ <= this->target_position_;
|
||||
if (reached)
|
||||
this->stop_door();
|
||||
}
|
||||
|
||||
void HoermannHcp::on_state_reg_(uint16_t value) {
|
||||
// The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
|
||||
const uint16_t previous = this->prev_state_reg_;
|
||||
this->prev_state_reg_ = value;
|
||||
if (previous == value)
|
||||
return;
|
||||
|
||||
const uint8_t state = value >> 8;
|
||||
if (state == 0x00) {
|
||||
// Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
|
||||
this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
|
||||
return;
|
||||
}
|
||||
for (const auto &mapping : DOOR_STATE_MAPPINGS) {
|
||||
if (mapping.code == state) {
|
||||
this->set_door_state_(mapping.state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The low byte can change on its own, so only report a state we cannot decode once.
|
||||
if (state != (previous >> 8))
|
||||
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
|
||||
}
|
||||
|
||||
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
if (!this->valid_) {
|
||||
// Queueing now would fire the command whenever the controller comes back, which may be much later.
|
||||
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
|
||||
return false;
|
||||
}
|
||||
if (this->next_command_ != nullptr) {
|
||||
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
|
||||
return false;
|
||||
}
|
||||
// A new command supersedes any half-open target the door was still travelling to.
|
||||
this->clear_target_();
|
||||
this->next_command_ = &command;
|
||||
this->command_queued_at_ = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
|
||||
bool HoermannHcp::stop_door() {
|
||||
if (!is_moving(this->door_state_)) {
|
||||
this->clear_target_();
|
||||
return true;
|
||||
}
|
||||
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
|
||||
return this->queue_command_(COMMAND_IMPULSE);
|
||||
}
|
||||
|
||||
bool HoermannHcp::set_position(float position) {
|
||||
// The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
|
||||
if (position <= CLOSE_POSITION_THRESHOLD)
|
||||
return this->close_door();
|
||||
if (position >= OPEN_POSITION_THRESHOLD)
|
||||
return this->open_door();
|
||||
// Asking the door to travel to where it already is means stopping it.
|
||||
if (position == this->current_position_)
|
||||
return this->stop_door();
|
||||
|
||||
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
|
||||
const bool opening = position > this->current_position_;
|
||||
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
|
||||
return false;
|
||||
this->target_position_ = position;
|
||||
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
|
||||
// A door already travelling that way is on its way; one moving the other way has to turn around first.
|
||||
this->target_started_ = this->door_state_ == this->target_direction_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HoermannHcp::record_response_() {
|
||||
this->last_response_ = millis();
|
||||
this->set_valid_(true);
|
||||
}
|
||||
|
||||
void HoermannHcp::set_valid_(bool valid) {
|
||||
if (this->valid_ == valid)
|
||||
return;
|
||||
this->valid_ = valid;
|
||||
this->changed_ = true;
|
||||
if (valid) {
|
||||
ESP_LOGI(TAG, "Bus controller connected");
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
|
||||
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
}
|
||||
|
||||
void HoermannHcp::set_door_state_(DoorState state) {
|
||||
if (this->door_state_ == state)
|
||||
return;
|
||||
this->door_state_ = state;
|
||||
this->changed_ = true;
|
||||
this->update_current_position_();
|
||||
if (!this->has_target_())
|
||||
return;
|
||||
if (state == this->target_direction_) {
|
||||
this->target_started_ = true;
|
||||
} else if (this->target_started_ && !is_moving(state)) {
|
||||
// The door came to rest without reaching the target, so the request it belonged to is over.
|
||||
this->clear_target_();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::update_current_position_() {
|
||||
// Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
|
||||
// trust the reported end stop over the raw count.
|
||||
float position = static_cast<float>(this->position_raw_) / 200.0f;
|
||||
if (this->door_state_ == DoorState::CLOSED) {
|
||||
position = 0.0f;
|
||||
} else if (this->door_state_ == DoorState::OPEN) {
|
||||
position = 1.0f;
|
||||
}
|
||||
if (this->current_position_ != position) {
|
||||
this->current_position_ = position;
|
||||
this->changed_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::clear_target_() {
|
||||
this->target_position_ = 0.0f;
|
||||
this->target_started_ = false;
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -1,110 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
// Door state as reported by the Hoermann bus controller.
|
||||
enum class DoorState : uint8_t {
|
||||
OPEN,
|
||||
OPENING,
|
||||
CLOSED,
|
||||
CLOSING,
|
||||
HALF_OPEN,
|
||||
MOVE_VENTING,
|
||||
VENT,
|
||||
MOVE_HALF,
|
||||
STOPPED,
|
||||
};
|
||||
|
||||
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
|
||||
// short delay the released value. The second command register remains zero.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
uint16_t released_value;
|
||||
};
|
||||
|
||||
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
public:
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
// Registered by child entities to be notified when the door state changes.
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
// Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write
|
||||
// half first, storing the command register that the read half echoes back) and broadcasts status with 0x10.
|
||||
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®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);
|
||||
|
||||
DoorState get_door_state() const { return this->door_state_; }
|
||||
float get_current_position() const { return this->current_position_; }
|
||||
bool is_valid() const { return this->valid_; }
|
||||
|
||||
protected:
|
||||
void record_response_();
|
||||
// Returns false when the bus controller has not fetched the previous command yet.
|
||||
bool queue_command_(const HoermannHcpCommand &command);
|
||||
// Appends the two key-press registers and advances the pending command's press/release state.
|
||||
void push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
|
||||
void set_valid_(bool valid);
|
||||
void set_door_state_(DoorState state);
|
||||
// Recomputes the reported position from position_raw_ and the current door state.
|
||||
void update_current_position_();
|
||||
bool has_target_() const { return this->target_position_ != 0.0f; }
|
||||
void clear_target_();
|
||||
|
||||
CallbackManager<void()> state_callback_;
|
||||
|
||||
float current_position_{0.0f};
|
||||
// Position the door was told to travel to; 0.0 means no target is armed.
|
||||
float target_position_{0.0f};
|
||||
|
||||
// Pending command / key-press state machine.
|
||||
const HoermannHcpCommand *next_command_{nullptr};
|
||||
uint32_t command_queued_at_{0};
|
||||
uint32_t command_written_at_{0};
|
||||
uint32_t last_response_{0};
|
||||
|
||||
// A command is "pressed" for this long before its end value is sent.
|
||||
uint16_t key_press_delay_ms_{100};
|
||||
// Drop the "connected" flag if the bus controller has not polled us for this long.
|
||||
uint16_t connection_timeout_ms_{2000};
|
||||
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
|
||||
// it reads 0x0000.
|
||||
uint16_t prev_state_reg_{0xFFFF};
|
||||
// 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message
|
||||
// counter and low-byte command back from STATE_REG.
|
||||
uint16_t command_reg_value_{0};
|
||||
|
||||
DoorState door_state_{DoorState::CLOSED};
|
||||
// Direction the door was started in for the current target. A target armed while the door is still travelling
|
||||
// the other way must not be judged by the reported direction until the door has turned around.
|
||||
DoorState target_direction_{DoorState::STOPPED};
|
||||
// Position as reported by the bus controller, 0..200 across the full travel.
|
||||
uint8_t position_raw_{0};
|
||||
bool target_started_{false};
|
||||
bool valid_{false};
|
||||
bool changed_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -8,7 +8,6 @@
|
||||
#endif
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ld2410 {
|
||||
|
||||
@@ -179,7 +178,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
|
||||
}
|
||||
|
||||
void LD2410Component::dump_config() {
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
char version_s[20];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ld24xx::format_version_str(this->version_, version_s);
|
||||
@@ -512,7 +511,7 @@ bool LD2410Component::handle_ack_data_() {
|
||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
||||
}
|
||||
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ESP_LOGV(TAG, "MAC address: %s", mac_str);
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice {
|
||||
uint8_t out_pin_level_ = 0;
|
||||
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
|
||||
uint8_t buffer_data_[MAX_LINE_LENGTH];
|
||||
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
|
||||
bool bluetooth_on_{false};
|
||||
#ifdef USE_NUMBER
|
||||
|
||||
@@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
|
||||
}
|
||||
|
||||
void LD2412Component::dump_config() {
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
char version_s[20];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ld24xx::format_version_str(this->version_, version_s);
|
||||
@@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() {
|
||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
||||
}
|
||||
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ESP_LOGV(TAG, "MAC address: %s", mac_str);
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice {
|
||||
uint8_t out_pin_level_ = 0;
|
||||
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
|
||||
uint8_t buffer_data_[MAX_LINE_LENGTH];
|
||||
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
|
||||
bool bluetooth_on_{false};
|
||||
bool dynamic_background_correction_active_{false};
|
||||
|
||||
@@ -184,7 +184,7 @@ void LD2450Component::setup() {
|
||||
}
|
||||
|
||||
void LD2450Component::dump_config() {
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
char version_s[20];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ld24xx::format_version_str(this->version_, version_s);
|
||||
@@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() {
|
||||
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
|
||||
}
|
||||
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
char mac_s[18];
|
||||
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
|
||||
ESP_LOGV(TAG, "MAC address: %s", mac_str);
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
||||
uint32_t moving_presence_millis_ = 0;
|
||||
uint32_t timeout_ = 5;
|
||||
uint8_t buffer_data_[MAX_LINE_LENGTH];
|
||||
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
|
||||
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
|
||||
uint8_t zone_type_ = 0;
|
||||
|
||||
@@ -45,7 +45,8 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X";
|
||||
|
||||
// Helper function to format MAC address with stack allocation
|
||||
// Returns pointer to UNKNOWN_MAC constant or formatted buffer
|
||||
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
|
||||
// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator)
|
||||
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, 18> buffer) {
|
||||
if (mac_address_is_valid(mac_address)) {
|
||||
format_mac_addr_upper(mac_address, buffer.data());
|
||||
return buffer.data();
|
||||
|
||||
@@ -3,7 +3,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_WAKEUP_PIN
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE
|
||||
|
||||
@@ -15,7 +14,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b")
|
||||
LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice)
|
||||
|
||||
|
||||
def _validate_wakeup_options(config: ConfigType) -> ConfigType:
|
||||
def _validate_wakeup_options(config):
|
||||
"""Reject wake options that would silently do nothing.
|
||||
|
||||
Runs before the schema so the defaults for the keys below have not been
|
||||
|
||||
@@ -4,35 +4,24 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY
|
||||
|
||||
from . import LD6002BComponent
|
||||
from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS
|
||||
from .const import CONF_LD6002B_ID, MAX_TARGETS
|
||||
|
||||
DEPENDENCIES = ["ld6002b"]
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
)
|
||||
for i in range(MAX_TARGETS)
|
||||
}
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
)
|
||||
for i in range(AREA_COUNT)
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
),
|
||||
}
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_OCCUPANCY,
|
||||
)
|
||||
for i in range(MAX_TARGETS)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -47,8 +36,3 @@ async def to_code(config):
|
||||
if target_config := config.get(f"target_{i + 1}"):
|
||||
sens = await binary_sensor.new_binary_sensor(target_config)
|
||||
cg.add(hub.set_target_presence_binary_sensor(i, sens))
|
||||
|
||||
for i in range(AREA_COUNT):
|
||||
if area_config := config.get(f"detection_area_{i}"):
|
||||
sens = await binary_sensor.new_binary_sensor(area_config)
|
||||
cg.add(hub.set_area_presence_binary_sensor(i, sens))
|
||||
@@ -2,21 +2,15 @@ import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AREA_ID,
|
||||
CONF_ID,
|
||||
CONF_WAKEUP_PIN,
|
||||
ENTITY_CATEGORY_CONFIG,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import LD6002BComponent, ld6002b_ns
|
||||
from ..const import (
|
||||
CONF_APPLY_AREA,
|
||||
CONF_AUTO_INTERFERENCE,
|
||||
CONF_CLEAR_INTERFERENCE,
|
||||
CONF_GET_AREAS,
|
||||
CONF_GET_DELAY,
|
||||
CONF_GET_INSTALLATION,
|
||||
CONF_GET_LOW_POWER_MODE,
|
||||
@@ -25,7 +19,6 @@ from ..const import (
|
||||
CONF_GET_TRIGGER_SPEED,
|
||||
CONF_GET_Z_RANGE,
|
||||
CONF_LD6002B_ID,
|
||||
CONF_RESET_DETECTION_AREA,
|
||||
CONF_RESET_UNATTENDED,
|
||||
CONF_WAKE,
|
||||
)
|
||||
@@ -38,21 +31,6 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_APPLY_AREA): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
cv.Optional(CONF_GET_AREAS): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
|
||||
),
|
||||
cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
cv.Optional(CONF_GET_DELAY): button.button_schema(
|
||||
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
|
||||
),
|
||||
@@ -84,21 +62,10 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> ConfigType:
|
||||
def final_validate(config):
|
||||
full_config = fv.full_config.get()
|
||||
hub_id = config[CONF_LD6002B_ID]
|
||||
|
||||
if config.get(CONF_APPLY_AREA):
|
||||
has_area_id_select = any(
|
||||
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None
|
||||
for entry in full_config.get("select", [])
|
||||
)
|
||||
if not has_area_id_select:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance",
|
||||
path=[CONF_APPLY_AREA],
|
||||
)
|
||||
|
||||
if config.get(CONF_WAKE):
|
||||
hub_path = full_config.get_path_for_id(hub_id)
|
||||
hub_config = full_config.get_config_for_path(hub_path[:-1])
|
||||
@@ -114,11 +81,6 @@ def final_validate(config: ConfigType) -> ConfigType:
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
BUTTON_MAP = {
|
||||
CONF_APPLY_AREA: ButtonType.APPLY_AREA,
|
||||
CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE,
|
||||
CONF_GET_AREAS: ButtonType.GET_AREAS,
|
||||
CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE,
|
||||
CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA,
|
||||
CONF_GET_DELAY: ButtonType.GET_DELAY,
|
||||
CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY,
|
||||
CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
CONF_APPLY_AREA = "apply_area"
|
||||
CONF_AREA_CONFIG = "area_config"
|
||||
CONF_AUTO_INTERFERENCE = "auto_interference"
|
||||
CONF_AUTO_WAKE = "auto_wake"
|
||||
CONF_CLEAR_INTERFERENCE = "clear_interference"
|
||||
CONF_CLUSTER_ID = "cluster_id"
|
||||
CONF_DOPPLER_INDEX = "doppler_index"
|
||||
CONF_GET_AREAS = "get_areas"
|
||||
CONF_GET_DELAY = "get_delay"
|
||||
CONF_GET_INSTALLATION = "get_installation"
|
||||
CONF_GET_LOW_POWER_MODE = "get_low_power_mode"
|
||||
@@ -21,7 +16,6 @@ CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time"
|
||||
CONF_OTA_VERSION = "ota_version"
|
||||
CONF_POINT_CLOUD = "point_cloud"
|
||||
CONF_POINT_COUNT = "point_count"
|
||||
CONF_RESET_DETECTION_AREA = "reset_detection_area"
|
||||
CONF_RESET_UNATTENDED = "reset_unattended"
|
||||
CONF_TARGET_DISPLAY = "target_display"
|
||||
CONF_TRIGGER_SPEED = "trigger_speed"
|
||||
@@ -32,10 +26,4 @@ CONF_Z = "z"
|
||||
CONF_Z_MAX = "z_max"
|
||||
CONF_Z_MIN = "z_min"
|
||||
|
||||
KEY_X_MIN = "x_min"
|
||||
KEY_X_MAX = "x_max"
|
||||
KEY_Y_MIN = "y_min"
|
||||
KEY_Y_MAX = "y_max"
|
||||
|
||||
AREA_COUNT = 4
|
||||
MAX_TARGETS = 3
|
||||
@@ -15,16 +15,12 @@ static constexpr uint32_t SETUP_DELAY_MS = 100;
|
||||
|
||||
// Command/message types
|
||||
static constexpr uint16_t TYPE_CONTROL = 0x0201;
|
||||
static constexpr uint16_t TYPE_SET_AREA = 0x0202;
|
||||
static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203;
|
||||
static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204;
|
||||
static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205;
|
||||
|
||||
static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04;
|
||||
static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08;
|
||||
static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A;
|
||||
static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B;
|
||||
static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C;
|
||||
static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D;
|
||||
static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E;
|
||||
static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F;
|
||||
@@ -36,10 +32,6 @@ static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14;
|
||||
static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF;
|
||||
|
||||
// Control command values for TYPE_CONTROL
|
||||
static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01;
|
||||
static constexpr uint32_t CMD_GET_AREAS = 0x02;
|
||||
static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03;
|
||||
static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04;
|
||||
static constexpr uint32_t CMD_GET_DELAY = 0x05;
|
||||
static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06;
|
||||
static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07;
|
||||
@@ -63,26 +55,13 @@ static constexpr uint32_t CMD_GET_LOW_POWER = 0x18;
|
||||
static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19;
|
||||
static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A;
|
||||
|
||||
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
|
||||
static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats
|
||||
static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats
|
||||
static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area
|
||||
|
||||
static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display
|
||||
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
|
||||
|
||||
static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00};
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
static const char *control_command_name(uint32_t command) {
|
||||
switch (command) {
|
||||
case CMD_AUTO_INTERFERENCE:
|
||||
return "auto_interference";
|
||||
case CMD_GET_AREAS:
|
||||
return "get_areas";
|
||||
case CMD_CLEAR_INTERFERENCE:
|
||||
return "clear_interference";
|
||||
case CMD_RESET_DETECTION_AREA:
|
||||
return "reset_detection_area";
|
||||
case CMD_GET_DELAY:
|
||||
return "get_delay";
|
||||
case CMD_POINT_CLOUD_ON:
|
||||
@@ -136,8 +115,6 @@ static const char *frame_type_name(uint16_t type) {
|
||||
switch (type) {
|
||||
case TYPE_CONTROL:
|
||||
return "control";
|
||||
case TYPE_SET_AREA:
|
||||
return "set_area";
|
||||
case TYPE_SET_HOLD_DELAY:
|
||||
return "set_hold_delay";
|
||||
case TYPE_SET_Z_RANGE:
|
||||
@@ -148,12 +125,6 @@ static const char *frame_type_name(uint16_t type) {
|
||||
return "report_target";
|
||||
case TYPE_REPORT_POINT_CLOUD:
|
||||
return "report_point_cloud";
|
||||
case TYPE_REPORT_AREA_PRESENCE:
|
||||
return "report_area_presence";
|
||||
case TYPE_REPORT_INTERFERENCE_AREAS:
|
||||
return "report_interference_areas";
|
||||
case TYPE_REPORT_DETECTION_AREAS:
|
||||
return "report_detection_areas";
|
||||
case TYPE_REPORT_DELAY:
|
||||
return "report_delay";
|
||||
case TYPE_REPORT_SENSITIVITY:
|
||||
@@ -179,8 +150,6 @@ static const char *frame_type_name(uint16_t type) {
|
||||
|
||||
static bool is_expected_control_report(uint32_t command, uint16_t type) {
|
||||
switch (command) {
|
||||
case CMD_GET_AREAS:
|
||||
return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS;
|
||||
case CMD_GET_DELAY:
|
||||
return type == TYPE_REPORT_DELAY;
|
||||
case CMD_GET_SENSITIVITY:
|
||||
@@ -231,10 +200,6 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) {
|
||||
data[3] = (value >> 24) & 0xFF;
|
||||
}
|
||||
|
||||
void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) {
|
||||
write_u32_le(data, static_cast<uint32_t>(value));
|
||||
}
|
||||
|
||||
void LD6002BComponent::write_f32_le(uint8_t *data, float value) {
|
||||
uint32_t raw;
|
||||
std::memcpy(&raw, &value, sizeof(raw));
|
||||
@@ -391,37 +356,6 @@ void LD6002BComponent::setup() {
|
||||
this->send_control_command_(CMD_GET_LOW_POWER);
|
||||
}
|
||||
|
||||
bool want_area_report = false;
|
||||
#ifdef USE_SENSOR
|
||||
for (const auto &area : this->interference_areas_) {
|
||||
if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr ||
|
||||
area.z_min != nullptr || area.z_max != nullptr) {
|
||||
want_area_report = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!want_area_report) {
|
||||
for (const auto &area : this->detection_areas_) {
|
||||
if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr ||
|
||||
area.z_min != nullptr || area.z_max != nullptr) {
|
||||
want_area_report = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr ||
|
||||
this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr ||
|
||||
this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) {
|
||||
want_area_report = true;
|
||||
}
|
||||
#endif
|
||||
if (want_area_report) {
|
||||
this->send_control_command_(CMD_GET_AREAS);
|
||||
}
|
||||
|
||||
this->init_area_id_pref_();
|
||||
this->init_version_pref_();
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
@@ -440,7 +374,7 @@ void LD6002BComponent::dump_config() {
|
||||
this->auto_wake_ ? "true" : "false", static_cast<unsigned>(this->max_data_len_));
|
||||
if (this->wakeup_pin_ != nullptr) {
|
||||
LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_);
|
||||
ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_);
|
||||
ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_);
|
||||
}
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Target Count", this->target_count_sensor_);
|
||||
@@ -452,31 +386,12 @@ void LD6002BComponent::dump_config() {
|
||||
LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx);
|
||||
LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id);
|
||||
}
|
||||
for (auto &area : this->interference_areas_) {
|
||||
LOG_SENSOR(" ", "Interference Area X Min", area.x_min);
|
||||
LOG_SENSOR(" ", "Interference Area X Max", area.x_max);
|
||||
LOG_SENSOR(" ", "Interference Area Y Min", area.y_min);
|
||||
LOG_SENSOR(" ", "Interference Area Y Max", area.y_max);
|
||||
LOG_SENSOR(" ", "Interference Area Z Min", area.z_min);
|
||||
LOG_SENSOR(" ", "Interference Area Z Max", area.z_max);
|
||||
}
|
||||
for (auto &area : this->detection_areas_) {
|
||||
LOG_SENSOR(" ", "Detection Area X Min", area.x_min);
|
||||
LOG_SENSOR(" ", "Detection Area X Max", area.x_max);
|
||||
LOG_SENSOR(" ", "Detection Area Y Min", area.y_min);
|
||||
LOG_SENSOR(" ", "Detection Area Y Max", area.y_max);
|
||||
LOG_SENSOR(" ", "Detection Area Z Min", area.z_min);
|
||||
LOG_SENSOR(" ", "Detection Area Z Max", area.z_max);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_);
|
||||
for (uint8_t i = 0; i < MAX_TARGETS; i++) {
|
||||
LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]);
|
||||
}
|
||||
for (uint8_t i = 0; i < AREA_COUNT; i++) {
|
||||
LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_);
|
||||
@@ -487,12 +402,6 @@ void LD6002BComponent::dump_config() {
|
||||
LOG_NUMBER(" ", "Z Min", this->z_min_number_);
|
||||
LOG_NUMBER(" ", "Z Max", this->z_max_number_);
|
||||
LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_);
|
||||
LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_);
|
||||
LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_);
|
||||
LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_);
|
||||
LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_);
|
||||
LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_);
|
||||
LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_);
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
LOG_SWITCH(" ", "Low Power", this->low_power_switch_);
|
||||
@@ -503,7 +412,6 @@ void LD6002BComponent::dump_config() {
|
||||
LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_);
|
||||
LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_);
|
||||
LOG_SELECT(" ", "Installation Mode", this->installation_select_);
|
||||
LOG_SELECT(" ", "Area ID", this->area_id_select_);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -619,7 +527,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
|
||||
}
|
||||
if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) {
|
||||
ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_);
|
||||
const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_;
|
||||
// This settles one expected reply; the rest stay owed and become the debt for the next command.
|
||||
this->send_generation_++;
|
||||
this->stale_ack_type_ = type;
|
||||
@@ -629,10 +536,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
|
||||
this->command_sent_ = false;
|
||||
this->last_send_ms_ = 0;
|
||||
this->process_command_queue_();
|
||||
if (refresh_areas) {
|
||||
this->area_write_in_flight_ = false;
|
||||
this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -654,15 +557,6 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
|
||||
case TYPE_REPORT_POINT_CLOUD:
|
||||
this->handle_point_cloud_(data, len);
|
||||
break;
|
||||
case TYPE_REPORT_AREA_PRESENCE:
|
||||
this->handle_area_presence_(data, len);
|
||||
break;
|
||||
case TYPE_REPORT_INTERFERENCE_AREAS:
|
||||
this->handle_area_report_(true, data, len);
|
||||
break;
|
||||
case TYPE_REPORT_DETECTION_AREAS:
|
||||
this->handle_area_report_(false, data, len);
|
||||
break;
|
||||
case TYPE_REPORT_DELAY:
|
||||
this->handle_delay_report_(data, len);
|
||||
break;
|
||||
@@ -760,9 +654,8 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len)
|
||||
|
||||
this->target_presence_any_ = (reported > 0);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool presence = this->target_presence_any_ || this->area_presence_any_;
|
||||
if (this->presence_binary_sensor_ != nullptr) {
|
||||
this->presence_binary_sensor_->publish_state(presence);
|
||||
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
|
||||
}
|
||||
#endif
|
||||
this->update_work_mode_fallback_();
|
||||
@@ -835,84 +728,6 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) {
|
||||
#endif
|
||||
}
|
||||
|
||||
// 0x0A0A carries one uint32 per detection area -- the protocol names the four
|
||||
// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The
|
||||
// interference areas have no presence report: a target inside one is what they
|
||||
// exist to suppress.
|
||||
void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) {
|
||||
const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN;
|
||||
if (len < needed)
|
||||
return;
|
||||
|
||||
this->area_presence_any_ = false;
|
||||
for (uint8_t i = 0; i < AREA_COUNT; i++) {
|
||||
uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN));
|
||||
bool present = state != 0;
|
||||
this->area_presence_any_ = this->area_presence_any_ || present;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->area_presence_[i] != nullptr) {
|
||||
this->area_presence_[i]->publish_state(present);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool presence = this->target_presence_any_ || this->area_presence_any_;
|
||||
if (this->presence_binary_sensor_ != nullptr) {
|
||||
this->presence_binary_sensor_->publish_state(presence);
|
||||
}
|
||||
#endif
|
||||
this->update_work_mode_fallback_();
|
||||
}
|
||||
|
||||
void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) {
|
||||
uint16_t needed = AREA_COUNT * AREA_DATA_LEN;
|
||||
if (len < needed)
|
||||
return;
|
||||
|
||||
for (uint8_t i = 0; i < AREA_COUNT; i++) {
|
||||
uint16_t offset = i * AREA_DATA_LEN;
|
||||
float x_min = read_f32_le(data + offset + 0);
|
||||
float x_max = read_f32_le(data + offset + 4);
|
||||
float y_min = read_f32_le(data + offset + 8);
|
||||
float y_max = read_f32_le(data + offset + 12);
|
||||
float z_min = read_f32_le(data + offset + 16);
|
||||
float z_max = read_f32_le(data + offset + 20);
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i];
|
||||
if (area.x_min != nullptr)
|
||||
area.x_min->publish_state(x_min);
|
||||
if (area.x_max != nullptr)
|
||||
area.x_max->publish_state(x_max);
|
||||
if (area.y_min != nullptr)
|
||||
area.y_min->publish_state(y_min);
|
||||
if (area.y_max != nullptr)
|
||||
area.y_max->publish_state(y_max);
|
||||
if (area.z_min != nullptr)
|
||||
area.z_min->publish_state(z_min);
|
||||
if (area.z_max != nullptr)
|
||||
area.z_max->publish_state(z_max);
|
||||
#endif
|
||||
|
||||
AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i];
|
||||
store.x_min = x_min;
|
||||
store.x_max = x_max;
|
||||
store.y_min = y_min;
|
||||
store.y_max = y_max;
|
||||
store.z_min = z_min;
|
||||
store.z_max = z_max;
|
||||
|
||||
uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
|
||||
bool selected_interference = selected_id < AREA_COUNT;
|
||||
uint8_t selected_index = selected_interference ? selected_id : static_cast<uint8_t>(selected_id - AREA_COUNT);
|
||||
if (selected_interference == interference && selected_index == i) {
|
||||
this->update_area_numbers_(store);
|
||||
}
|
||||
}
|
||||
this->try_apply_pending_area_(interference);
|
||||
}
|
||||
|
||||
void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 4)
|
||||
return;
|
||||
@@ -1000,24 +815,13 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1
|
||||
void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 1)
|
||||
return;
|
||||
// Zero is the unattended half of this transition. Read outside the text sensor's
|
||||
// ifdef because the area sensors do not need one configured to have gone stale.
|
||||
const bool low_power = (data[0] == 0);
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
const bool low_power = (data[0] == 0);
|
||||
if (this->work_mode_text_sensor_ != nullptr) {
|
||||
this->work_mode_reported_ = true;
|
||||
this->publish_work_mode_(low_power);
|
||||
}
|
||||
#endif
|
||||
// Protocol V1.2 section 2.1.17: this message is sent only on the transition
|
||||
// between the unattended low-power mode and normal operation, so a zero is the
|
||||
// module stating that nobody is in any area. Not while a target is still being
|
||||
// tracked, though: the reset_unattended command is undocumented on whether it
|
||||
// forces this report, and where two statements from the module disagree the live
|
||||
// one wins.
|
||||
if (low_power && !this->target_presence_any_) {
|
||||
this->clear_area_presence_();
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::update_work_mode_fallback_() {
|
||||
@@ -1028,10 +832,9 @@ void LD6002BComponent::update_work_mode_fallback_() {
|
||||
if (!this->low_power_reported_) {
|
||||
return;
|
||||
}
|
||||
// Target presence is only meaningful while the stream that maintains it runs.
|
||||
// Area presence keeps its own report, so it still counts with the target stream
|
||||
// off and low power alone decides only when neither half has anything to say.
|
||||
const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_;
|
||||
// Presence is only meaningful while the stream that maintains it runs; with it
|
||||
// off there is nothing to weigh and low power alone decides.
|
||||
const bool presence = this->target_display_enabled_ && this->target_presence_any_;
|
||||
this->publish_work_mode_(this->low_power_enabled_ && !presence);
|
||||
#endif
|
||||
}
|
||||
@@ -1054,16 +857,6 @@ void LD6002BComponent::publish_work_mode_(bool low_power) {
|
||||
void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) {
|
||||
if (number == nullptr)
|
||||
return;
|
||||
if (std::isnan(value)) {
|
||||
// NAN is this component's "the module has not told us yet". Publishing it on an
|
||||
// entity that has never had a state would report a nan where unknown is the
|
||||
// truth; on one that already shows a value it is the only way to say that value
|
||||
// no longer describes the selected area.
|
||||
if (number->has_state()) {
|
||||
number->publish_state(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const float min_value = number->traits.get_min_value();
|
||||
const float max_value = number->traits.get_max_value();
|
||||
// Outside the declared range the user cannot write the value back, so publish
|
||||
@@ -1098,14 +891,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len)
|
||||
#endif
|
||||
}
|
||||
|
||||
bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) {
|
||||
void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) {
|
||||
if (len > CMD_MAX_DATA_LEN) {
|
||||
ESP_LOGW(TAG, "Command data too large: %u", len);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
if (this->cmd_count_ >= CMD_QUEUE_SIZE) {
|
||||
ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_];
|
||||
@@ -1118,7 +911,6 @@ bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_
|
||||
this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE;
|
||||
this->cmd_count_++;
|
||||
this->process_command_queue_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void LD6002BComponent::process_command_queue_() {
|
||||
@@ -1153,18 +945,6 @@ void LD6002BComponent::process_command_queue_() {
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type);
|
||||
}
|
||||
if (this->active_command_.type == TYPE_SET_AREA) {
|
||||
this->area_write_in_flight_ = false;
|
||||
}
|
||||
// The deferred apply is waiting on the report this command would have
|
||||
// brought back, and nothing else re-arms it. Dropping it here is the
|
||||
// difference between one apply lost to a timeout and one that rides in on
|
||||
// an unrelated area report later, writing bounds the user has moved on from.
|
||||
if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) {
|
||||
this->deferred_apply_pending_ = false;
|
||||
this->restore_deferred_edits_();
|
||||
ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply");
|
||||
}
|
||||
// A reply may still be in flight for the attempt we just gave up on, so carry one over as
|
||||
// debt rather than clearing the ledger, or that late ACK would retire the successor. Only
|
||||
// one: reaching this point means nothing was answered at all, so the older attempts are
|
||||
@@ -1287,10 +1067,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t
|
||||
this->last_traffic_ms_ = now;
|
||||
}
|
||||
|
||||
bool LD6002BComponent::send_control_command_(uint32_t command) {
|
||||
void LD6002BComponent::send_control_command_(uint32_t command) {
|
||||
uint8_t data[4];
|
||||
write_u32_le(data, command);
|
||||
return this->queue_command_(TYPE_CONTROL, data, sizeof(data));
|
||||
this->queue_command_(TYPE_CONTROL, data, sizeof(data));
|
||||
}
|
||||
|
||||
void LD6002BComponent::send_z_range_() {
|
||||
@@ -1310,64 +1090,6 @@ void LD6002BComponent::send_z_range_() {
|
||||
this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data));
|
||||
}
|
||||
|
||||
void LD6002BComponent::apply_area_config_() {
|
||||
if (!this->area_id_set_) {
|
||||
ESP_LOGW(TAG, "Area ID not selected; ignoring apply");
|
||||
return;
|
||||
}
|
||||
if (this->area_id_ >= AREA_ID_COUNT) {
|
||||
ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_);
|
||||
return;
|
||||
}
|
||||
|
||||
const bool interference = this->area_id_ < AREA_COUNT;
|
||||
const uint8_t index = interference ? this->area_id_ : static_cast<uint8_t>(this->area_id_ - AREA_COUNT);
|
||||
AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
|
||||
if (!std::isnan(this->area_x_min_))
|
||||
desired.x_min = this->area_x_min_;
|
||||
if (!std::isnan(this->area_x_max_))
|
||||
desired.x_max = this->area_x_max_;
|
||||
if (!std::isnan(this->area_y_min_))
|
||||
desired.y_min = this->area_y_min_;
|
||||
if (!std::isnan(this->area_y_max_))
|
||||
desired.y_max = this->area_y_max_;
|
||||
if (!std::isnan(this->area_z_min_))
|
||||
desired.z_min = this->area_z_min_;
|
||||
if (!std::isnan(this->area_z_max_))
|
||||
desired.z_max = this->area_z_max_;
|
||||
|
||||
if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) ||
|
||||
std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) {
|
||||
// Ask first: a read that never reached the queue would leave a deferral waiting
|
||||
// on a report nobody requested, with the user's values already retired for it.
|
||||
if (!this->send_control_command_(CMD_GET_AREAS)) {
|
||||
ESP_LOGW(TAG, "Area read not queued; area config left unapplied");
|
||||
return;
|
||||
}
|
||||
this->deferred_apply_pending_ = true;
|
||||
this->pending_area_id_ = this->area_id_;
|
||||
// The ledger, not the mirror: the mirror also carries whatever the module last
|
||||
// reported for the axes the user never touched, and staging those would hand them
|
||||
// back later wearing the user's badge -- a module value the next report is then
|
||||
// kept away from. Staging only what was actually typed is also what makes the
|
||||
// replay's overlay right: the untouched axes come from the fresh report. An
|
||||
// empty ledger is a meaning rather than a gap, then: an apply with nothing
|
||||
// staged rewrites the area exactly as the report just described it, which is
|
||||
// what a direct apply with nothing staged already does.
|
||||
this->pending_area_updates_ = this->area_edits_;
|
||||
// Staged above, so they are the deferred apply's values now rather than an
|
||||
// unsent edit. Anything typed from here belongs to whatever the user does
|
||||
// next, which may well be a different area.
|
||||
this->area_edits_ = AreaConfig{};
|
||||
ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying");
|
||||
return;
|
||||
}
|
||||
// Only a write the module will actually see retires them.
|
||||
if (this->queue_area_config_(this->area_id_, desired)) {
|
||||
this->area_edits_ = AreaConfig{};
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::wake_() {
|
||||
// A command's own pulse raises the pin and writes after it, so ride along instead of
|
||||
// claiming the flag: claiming it would send that command down the immediate-write path
|
||||
@@ -1402,30 +1124,6 @@ void LD6002BComponent::set_number_value(NumberType type, float value) {
|
||||
this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data));
|
||||
break;
|
||||
}
|
||||
case NumberType::AREA_X_MIN:
|
||||
this->area_x_min_ = value;
|
||||
this->area_edits_.x_min = value;
|
||||
break;
|
||||
case NumberType::AREA_X_MAX:
|
||||
this->area_x_max_ = value;
|
||||
this->area_edits_.x_max = value;
|
||||
break;
|
||||
case NumberType::AREA_Y_MIN:
|
||||
this->area_y_min_ = value;
|
||||
this->area_edits_.y_min = value;
|
||||
break;
|
||||
case NumberType::AREA_Y_MAX:
|
||||
this->area_y_max_ = value;
|
||||
this->area_edits_.y_max = value;
|
||||
break;
|
||||
case NumberType::AREA_Z_MIN:
|
||||
this->area_z_min_ = value;
|
||||
this->area_edits_.z_min = value;
|
||||
break;
|
||||
case NumberType::AREA_Z_MAX:
|
||||
this->area_z_max_ = value;
|
||||
this->area_edits_.z_max = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,179 +1154,9 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) {
|
||||
this->send_control_command_(CMD_INSTALL_SIDE);
|
||||
}
|
||||
break;
|
||||
case SelectType::AREA_ID:
|
||||
this->area_id_ = static_cast<uint8_t>(index);
|
||||
this->area_id_set_ = true;
|
||||
this->update_area_numbers_for_id_(this->area_id_);
|
||||
this->save_area_id_pref_(this->area_id_);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::update_area_numbers_(const AreaConfig &area) {
|
||||
// A report refreshes every axis the user is not in the middle of changing. An
|
||||
// unapplied edit is the one value here the module cannot know about, so taking
|
||||
// the report over it would discard what the user typed with nothing to show for it.
|
||||
const AreaConfig &edits = this->area_edits_;
|
||||
if (std::isnan(edits.x_min))
|
||||
this->area_x_min_ = area.x_min;
|
||||
if (std::isnan(edits.x_max))
|
||||
this->area_x_max_ = area.x_max;
|
||||
if (std::isnan(edits.y_min))
|
||||
this->area_y_min_ = area.y_min;
|
||||
if (std::isnan(edits.y_max))
|
||||
this->area_y_max_ = area.y_max;
|
||||
if (std::isnan(edits.z_min))
|
||||
this->area_z_min_ = area.z_min;
|
||||
if (std::isnan(edits.z_max))
|
||||
this->area_z_max_ = area.z_max;
|
||||
this->publish_area_numbers_();
|
||||
}
|
||||
|
||||
// The mirror, not the report: an axis a report was kept away from has to keep its
|
||||
// displayed value too, or the entity and the value the next apply sends disagree.
|
||||
void LD6002BComponent::publish_area_numbers_() {
|
||||
#ifdef USE_NUMBER
|
||||
this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_);
|
||||
this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_);
|
||||
this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_);
|
||||
this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_);
|
||||
this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_);
|
||||
this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) {
|
||||
if (area_id >= AREA_ID_COUNT)
|
||||
return;
|
||||
const bool interference = area_id < AREA_COUNT;
|
||||
const uint8_t index = interference ? area_id : static_cast<uint8_t>(area_id - AREA_COUNT);
|
||||
const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
|
||||
// The edits belonged to the area being navigated away from.
|
||||
this->area_edits_ = AreaConfig{};
|
||||
this->update_area_numbers_(area);
|
||||
}
|
||||
|
||||
bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) {
|
||||
// One frame carries all three pairs and cannot express a crossed one; the module
|
||||
// would keep a box nothing can ever be inside. Both callers arrive with the six
|
||||
// bounds resolved, so this is the last place that can say no -- and the return
|
||||
// value is how saying no reaches the caller, which must not then retire the edits
|
||||
// the user still has to fix.
|
||||
if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) {
|
||||
ESP_LOGW(TAG, "Area %u not written, min above max", area_id);
|
||||
return false;
|
||||
}
|
||||
uint8_t data[AREA_CONFIG_LEN];
|
||||
write_int32_le(data, static_cast<int32_t>(area_id));
|
||||
write_f32_le(data + 4, desired.x_min);
|
||||
write_f32_le(data + 8, desired.x_max);
|
||||
write_f32_le(data + 12, desired.y_min);
|
||||
write_f32_le(data + 16, desired.y_max);
|
||||
write_f32_le(data + 20, desired.z_min);
|
||||
write_f32_le(data + 24, desired.z_max);
|
||||
|
||||
if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) {
|
||||
// Nothing is on its way, so the cache must not claim these bounds, the ack
|
||||
// refresh must not be armed for an ack that cannot come, and the values stay
|
||||
// the user's unsent edit.
|
||||
return false;
|
||||
}
|
||||
this->area_write_in_flight_ = true;
|
||||
|
||||
const bool interference = area_id < AREA_COUNT;
|
||||
const uint8_t index = interference ? area_id : static_cast<uint8_t>(area_id - AREA_COUNT);
|
||||
AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
|
||||
store = desired;
|
||||
// The six numbers show one area at a time, and a deferred apply can land here for
|
||||
// an area the user has navigated away from. Same question handle_area_report_
|
||||
// asks before it touches them.
|
||||
const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
|
||||
if (area_id == selected_id) {
|
||||
this->update_area_numbers_(store);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void LD6002BComponent::try_apply_pending_area_(bool reported_interference) {
|
||||
if (!this->deferred_apply_pending_) {
|
||||
return;
|
||||
}
|
||||
if (this->pending_area_id_ >= AREA_ID_COUNT) {
|
||||
this->deferred_apply_pending_ = false;
|
||||
return;
|
||||
}
|
||||
const bool interference = this->pending_area_id_ < AREA_COUNT;
|
||||
const uint8_t index =
|
||||
interference ? this->pending_area_id_ : static_cast<uint8_t>(this->pending_area_id_ - AREA_COUNT);
|
||||
AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index];
|
||||
|
||||
if (!std::isnan(this->pending_area_updates_.x_min))
|
||||
desired.x_min = this->pending_area_updates_.x_min;
|
||||
if (!std::isnan(this->pending_area_updates_.x_max))
|
||||
desired.x_max = this->pending_area_updates_.x_max;
|
||||
if (!std::isnan(this->pending_area_updates_.y_min))
|
||||
desired.y_min = this->pending_area_updates_.y_min;
|
||||
if (!std::isnan(this->pending_area_updates_.y_max))
|
||||
desired.y_max = this->pending_area_updates_.y_max;
|
||||
if (!std::isnan(this->pending_area_updates_.z_min))
|
||||
desired.z_min = this->pending_area_updates_.z_min;
|
||||
if (!std::isnan(this->pending_area_updates_.z_max))
|
||||
desired.z_max = this->pending_area_updates_.z_max;
|
||||
|
||||
if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) ||
|
||||
std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) {
|
||||
// Only the report covering this area's half can still fill it in, and there is
|
||||
// exactly one of those per read. Once it has landed with a bound still unknown,
|
||||
// nothing further is coming and waiting means waiting forever.
|
||||
if (reported_interference == interference) {
|
||||
this->deferred_apply_pending_ = false;
|
||||
this->restore_deferred_edits_();
|
||||
ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t area_id = this->pending_area_id_;
|
||||
this->deferred_apply_pending_ = false;
|
||||
if (!this->queue_area_config_(area_id, desired)) {
|
||||
// Nothing was queued, so this is a drop like the other two: hand the staged
|
||||
// values back rather than leaving them with no ledger to protect them.
|
||||
this->restore_deferred_edits_();
|
||||
}
|
||||
}
|
||||
|
||||
void LD6002BComponent::init_area_id_pref_() {
|
||||
#ifdef USE_SELECT
|
||||
if (this->area_id_select_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
this->area_id_pref_ = this->area_id_select_->make_entity_preference<uint8_t>();
|
||||
this->area_id_pref_initialized_ = true;
|
||||
|
||||
uint8_t value = 0;
|
||||
if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) {
|
||||
// No stored selection. The numbers are about to display this area either way,
|
||||
// so select it for real: a displayed area that apply_area then refuses to write
|
||||
// is the one combination the user cannot make sense of.
|
||||
value = AREA_ID_DEFAULT;
|
||||
}
|
||||
this->area_id_select_->publish_state(value);
|
||||
this->area_id_ = value;
|
||||
this->area_id_set_ = true;
|
||||
this->update_area_numbers_for_id_(value);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD6002BComponent::save_area_id_pref_(uint8_t value) {
|
||||
#ifdef USE_SELECT
|
||||
if (!this->area_id_pref_initialized_) {
|
||||
return;
|
||||
}
|
||||
this->area_id_pref_.save(&value);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD6002BComponent::init_version_pref_() {
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
if (this->ota_version_text_sensor_ == nullptr) {
|
||||
@@ -1683,74 +1211,6 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) {
|
||||
}
|
||||
#endif
|
||||
|
||||
void LD6002BComponent::restore_deferred_edits_() {
|
||||
// The staged values become an unsent edit again, but only for the user who is
|
||||
// still looking at the area they were staged for; anyone else's ledger belongs to
|
||||
// the area they are on now.
|
||||
const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT;
|
||||
if (this->pending_area_id_ != selected_id) {
|
||||
return;
|
||||
}
|
||||
// Axis by axis rather than a whole-struct assignment: the user can have edited
|
||||
// another bound while the deferral was in flight, and that edit is newer than
|
||||
// anything the deferral staged. Assigning over the ledger would drop it back to
|
||||
// NaN and let the next report take the value away. A live edit wins; only an axis
|
||||
// with nothing in the ledger takes its staged value back.
|
||||
//
|
||||
// The mirror moves with the ledger, because on the report path handle_area_report_
|
||||
// ran update_area_numbers_ before the replay, with the ledger still empty -- so the
|
||||
// mirror already holds the module's bounds and both the entities and the next apply
|
||||
// would build on them. On the timeout path no report arrived, the mirror still
|
||||
// holds the staged values, and this is an identity.
|
||||
const AreaConfig &staged = this->pending_area_updates_;
|
||||
if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) {
|
||||
this->area_edits_.x_min = staged.x_min;
|
||||
this->area_x_min_ = staged.x_min;
|
||||
}
|
||||
if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) {
|
||||
this->area_edits_.x_max = staged.x_max;
|
||||
this->area_x_max_ = staged.x_max;
|
||||
}
|
||||
if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) {
|
||||
this->area_edits_.y_min = staged.y_min;
|
||||
this->area_y_min_ = staged.y_min;
|
||||
}
|
||||
if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) {
|
||||
this->area_edits_.y_max = staged.y_max;
|
||||
this->area_y_max_ = staged.y_max;
|
||||
}
|
||||
if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) {
|
||||
this->area_edits_.z_min = staged.z_min;
|
||||
this->area_z_min_ = staged.z_min;
|
||||
}
|
||||
if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) {
|
||||
this->area_edits_.z_max = staged.z_max;
|
||||
this->area_z_max_ = staged.z_max;
|
||||
}
|
||||
this->publish_area_numbers_();
|
||||
}
|
||||
|
||||
void LD6002BComponent::clear_area_presence_() {
|
||||
if (!this->area_presence_any_) {
|
||||
return;
|
||||
}
|
||||
// Nothing else corrects this: 0x0A0A carries no period the protocol states and no
|
||||
// command stops it, so the module going unattended is the only moment the
|
||||
// component can know a stored "occupied" has stopped being true.
|
||||
this->area_presence_any_ = false;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
for (uint8_t i = 0; i < AREA_COUNT; i++) {
|
||||
if (this->area_presence_[i] != nullptr) {
|
||||
this->area_presence_[i]->publish_state(false);
|
||||
}
|
||||
}
|
||||
const bool presence = this->target_presence_any_ || this->area_presence_any_;
|
||||
if (this->presence_binary_sensor_ != nullptr) {
|
||||
this->presence_binary_sensor_->publish_state(presence);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD6002BComponent::clear_target_state_() {
|
||||
// Nothing corrects any of this until the stream comes back. The slot table goes
|
||||
// with it: slots key on cluster ids, which only track a person while reports are
|
||||
@@ -1782,9 +1242,8 @@ void LD6002BComponent::clear_target_state_() {
|
||||
if (this->target_presence_any_) {
|
||||
this->target_presence_any_ = false;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool presence = this->target_presence_any_ || this->area_presence_any_;
|
||||
if (this->presence_binary_sensor_ != nullptr) {
|
||||
this->presence_binary_sensor_->publish_state(presence);
|
||||
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
|
||||
}
|
||||
#endif
|
||||
this->update_work_mode_fallback_();
|
||||
@@ -1825,27 +1284,6 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) {
|
||||
|
||||
void LD6002BComponent::press_button(ButtonType type) {
|
||||
switch (type) {
|
||||
case ButtonType::APPLY_AREA:
|
||||
this->apply_area_config_();
|
||||
break;
|
||||
case ButtonType::AUTO_INTERFERENCE:
|
||||
this->send_control_command_(CMD_AUTO_INTERFERENCE);
|
||||
// The module recomputes the interference areas without reporting them.
|
||||
this->send_control_command_(CMD_GET_AREAS);
|
||||
break;
|
||||
case ButtonType::GET_AREAS:
|
||||
this->send_control_command_(CMD_GET_AREAS);
|
||||
break;
|
||||
case ButtonType::CLEAR_INTERFERENCE:
|
||||
this->send_control_command_(CMD_CLEAR_INTERFERENCE);
|
||||
// The module rewrites the areas but does not report them, so ask for the new geometry the
|
||||
// way the apply_area ack path does; the queue keeps it behind the command above.
|
||||
this->send_control_command_(CMD_GET_AREAS);
|
||||
break;
|
||||
case ButtonType::RESET_DETECTION_AREA:
|
||||
this->send_control_command_(CMD_RESET_DETECTION_AREA);
|
||||
this->send_control_command_(CMD_GET_AREAS);
|
||||
break;
|
||||
case ButtonType::GET_DELAY:
|
||||
this->send_control_command_(CMD_GET_DELAY);
|
||||
break;
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
namespace esphome::ld6002b {
|
||||
|
||||
static constexpr uint8_t MAX_TARGETS = 3;
|
||||
static constexpr uint8_t AREA_COUNT = 4;
|
||||
// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so
|
||||
// this is the whole id space TYPE_SET_AREA accepts.
|
||||
static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2;
|
||||
static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024;
|
||||
static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096;
|
||||
// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes.
|
||||
@@ -45,19 +41,12 @@ enum class NumberType : uint8_t {
|
||||
Z_MIN,
|
||||
Z_MAX,
|
||||
LOW_POWER_SLEEP,
|
||||
AREA_X_MIN,
|
||||
AREA_X_MAX,
|
||||
AREA_Y_MIN,
|
||||
AREA_Y_MAX,
|
||||
AREA_Z_MIN,
|
||||
AREA_Z_MAX,
|
||||
};
|
||||
|
||||
enum class SelectType : uint8_t {
|
||||
SENSITIVITY,
|
||||
TRIGGER_SPEED,
|
||||
INSTALLATION_MODE,
|
||||
AREA_ID,
|
||||
};
|
||||
|
||||
enum class SwitchType : uint8_t {
|
||||
@@ -67,11 +56,6 @@ enum class SwitchType : uint8_t {
|
||||
};
|
||||
|
||||
enum class ButtonType : uint8_t {
|
||||
APPLY_AREA,
|
||||
AUTO_INTERFERENCE,
|
||||
GET_AREAS,
|
||||
CLEAR_INTERFERENCE,
|
||||
RESET_DETECTION_AREA,
|
||||
GET_DELAY,
|
||||
GET_SENSITIVITY,
|
||||
GET_TRIGGER_SPEED,
|
||||
@@ -92,25 +76,8 @@ struct TargetSensors {
|
||||
sensor::Sensor *cluster_id{nullptr};
|
||||
};
|
||||
|
||||
struct AreaSensors {
|
||||
sensor::Sensor *x_min{nullptr};
|
||||
sensor::Sensor *x_max{nullptr};
|
||||
sensor::Sensor *y_min{nullptr};
|
||||
sensor::Sensor *y_max{nullptr};
|
||||
sensor::Sensor *z_min{nullptr};
|
||||
sensor::Sensor *z_max{nullptr};
|
||||
};
|
||||
#endif
|
||||
|
||||
struct AreaConfig {
|
||||
float x_min{NAN};
|
||||
float x_max{NAN};
|
||||
float y_min{NAN};
|
||||
float y_max{NAN};
|
||||
float z_min{NAN};
|
||||
float z_max{NAN};
|
||||
};
|
||||
|
||||
struct VersionPref {
|
||||
char value[20];
|
||||
};
|
||||
@@ -155,67 +122,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
return;
|
||||
this->targets_[target].cluster_id = sensor;
|
||||
}
|
||||
void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].x_min = sensor;
|
||||
}
|
||||
void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].x_max = sensor;
|
||||
}
|
||||
void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].y_min = sensor;
|
||||
}
|
||||
void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].y_max = sensor;
|
||||
}
|
||||
void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].z_min = sensor;
|
||||
}
|
||||
void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->interference_areas_[area].z_max = sensor;
|
||||
}
|
||||
|
||||
void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].x_min = sensor;
|
||||
}
|
||||
void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].x_max = sensor;
|
||||
}
|
||||
void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].y_min = sensor;
|
||||
}
|
||||
void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].y_max = sensor;
|
||||
}
|
||||
void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].z_min = sensor;
|
||||
}
|
||||
void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->detection_areas_[area].z_max = sensor;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -225,11 +131,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
return;
|
||||
this->target_presence_[target] = sensor;
|
||||
}
|
||||
void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) {
|
||||
if (area >= AREA_COUNT)
|
||||
return;
|
||||
this->area_presence_[area] = sensor;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
@@ -242,20 +143,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
void set_z_min_number(number::Number *number) { this->z_min_number_ = number; }
|
||||
void set_z_max_number(number::Number *number) { this->z_max_number_ = number; }
|
||||
void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; }
|
||||
|
||||
void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; }
|
||||
void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; }
|
||||
void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; }
|
||||
void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; }
|
||||
void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; }
|
||||
void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; }
|
||||
void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; }
|
||||
void set_installation_select(select::Select *select) { this->installation_select_ = select; }
|
||||
void set_area_id_select(select::Select *select) { this->area_id_select_ = select; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
@@ -283,8 +176,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len);
|
||||
void handle_target_report_(const uint8_t *data, uint16_t len);
|
||||
void handle_point_cloud_(const uint8_t *data, uint16_t len);
|
||||
void handle_area_presence_(const uint8_t *data, uint16_t len);
|
||||
void handle_area_report_(bool interference, const uint8_t *data, uint16_t len);
|
||||
void handle_delay_report_(const uint8_t *data, uint16_t len);
|
||||
void handle_sensitivity_report_(const uint8_t *data, uint16_t len);
|
||||
void handle_trigger_speed_report_(const uint8_t *data, uint16_t len);
|
||||
@@ -298,35 +189,22 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
void publish_work_mode_(bool low_power);
|
||||
// Drops every target-derived reading and the slot table they are indexed by.
|
||||
void clear_target_state_();
|
||||
void clear_area_presence_();
|
||||
void restore_deferred_edits_();
|
||||
void publish_area_numbers_();
|
||||
#ifdef USE_SENSOR
|
||||
void clear_target_slot_(uint8_t index);
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
void publish_number_clamped_(number::Number *number, float value);
|
||||
#endif
|
||||
void update_area_numbers_(const AreaConfig &area);
|
||||
void update_area_numbers_for_id_(uint8_t area_id);
|
||||
bool queue_area_config_(uint8_t area_id, const AreaConfig &desired);
|
||||
void try_apply_pending_area_(bool reported_interference);
|
||||
void init_area_id_pref_();
|
||||
void save_area_id_pref_(uint8_t value);
|
||||
void init_version_pref_();
|
||||
void save_version_pref_(const char *value);
|
||||
|
||||
// Returns whether the command was queued: it is dropped, with a log line, when
|
||||
// the payload is too long or the ring is full.
|
||||
bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len);
|
||||
void queue_command_(uint16_t type, const uint8_t *data, uint8_t len);
|
||||
void process_command_queue_();
|
||||
void send_command_(uint16_t type, const uint8_t *data, uint8_t len);
|
||||
void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
|
||||
void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
|
||||
// Returns whether the command reached the queue; see queue_command_.
|
||||
bool send_control_command_(uint32_t command);
|
||||
void send_control_command_(uint32_t command);
|
||||
void send_z_range_();
|
||||
void apply_area_config_();
|
||||
void wake_();
|
||||
|
||||
static uint16_t read_u16_be(const uint8_t *data);
|
||||
@@ -334,20 +212,16 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
static int32_t read_int32_le(const uint8_t *data);
|
||||
static float read_f32_le(const uint8_t *data);
|
||||
static void write_u32_le(uint8_t *data, uint32_t value);
|
||||
static void write_int32_le(uint8_t *data, int32_t value);
|
||||
static void write_f32_le(uint8_t *data, float value);
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
std::array<TargetSensors, MAX_TARGETS> targets_{};
|
||||
sensor::Sensor *target_count_sensor_{nullptr};
|
||||
sensor::Sensor *point_count_sensor_{nullptr};
|
||||
std::array<AreaSensors, AREA_COUNT> interference_areas_{};
|
||||
std::array<AreaSensors, AREA_COUNT> detection_areas_{};
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
|
||||
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
|
||||
std::array<binary_sensor::BinarySensor *, AREA_COUNT> area_presence_{};
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
text_sensor::TextSensor *work_mode_text_sensor_{nullptr};
|
||||
@@ -360,21 +234,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
number::Number *z_min_number_{nullptr};
|
||||
number::Number *z_max_number_{nullptr};
|
||||
number::Number *low_power_sleep_number_{nullptr};
|
||||
|
||||
number::Number *area_x_min_number_{nullptr};
|
||||
number::Number *area_x_max_number_{nullptr};
|
||||
number::Number *area_y_min_number_{nullptr};
|
||||
number::Number *area_y_max_number_{nullptr};
|
||||
number::Number *area_z_min_number_{nullptr};
|
||||
number::Number *area_z_max_number_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
select::Select *sensitivity_select_{nullptr};
|
||||
select::Select *trigger_speed_select_{nullptr};
|
||||
select::Select *installation_select_{nullptr};
|
||||
select::Select *area_id_select_{nullptr};
|
||||
ESPPreferenceObject area_id_pref_{};
|
||||
bool area_id_pref_initialized_{false};
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
switch_::Switch *low_power_switch_{nullptr};
|
||||
@@ -400,13 +264,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
uint8_t *data_buf_{nullptr};
|
||||
uint16_t next_frame_id_{0};
|
||||
|
||||
// Sized for the two bursts that reach it, both counted as what is still queued
|
||||
// once the first command is dequeued: boot leaves 11 with every platform
|
||||
// configured, and pressing all fourteen buttons before an ack lands leaves 15.
|
||||
// Neither overflowed 16, but one free slot is not headroom, and overflowing is a
|
||||
// dropped command with only a log line to show for it. Costs 256 bytes more per
|
||||
// configured instance, and this component is MULTI_CONF.
|
||||
static constexpr uint8_t CMD_QUEUE_SIZE = 24;
|
||||
// Sized for the boot burst: with every platform configured, setup() enqueues
|
||||
// roughly ten GET/config commands back to back before the first ack lands.
|
||||
static constexpr uint8_t CMD_QUEUE_SIZE = 16;
|
||||
static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300;
|
||||
// A sleeping module consumes the first frame to wake and answers only the one after it.
|
||||
static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600;
|
||||
@@ -416,9 +276,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
// Named so a repeated press replaces its own pending timeout instead of stacking
|
||||
// another, and so the command path can cancel it when it takes the pin over.
|
||||
static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button";
|
||||
// Named so a burst of writes collapses to one read once they settle, rather than
|
||||
// one read per write.
|
||||
static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh";
|
||||
// A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms.
|
||||
static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000;
|
||||
|
||||
@@ -450,24 +307,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
|
||||
float z_min_{NAN};
|
||||
float z_max_{NAN};
|
||||
float area_x_min_{NAN};
|
||||
float area_x_max_{NAN};
|
||||
float area_y_min_{NAN};
|
||||
float area_y_max_{NAN};
|
||||
float area_z_min_{NAN};
|
||||
float area_z_max_{NAN};
|
||||
// What the user has typed and not yet applied; NaN per axis means "nothing of
|
||||
// mine here, take the module's value". Same sentinel shape as
|
||||
// pending_area_updates_. Exactly two things empty it: the area_id select moving
|
||||
// to another area, and an apply that was accepted. A write the bounds guard
|
||||
// refused leaves it alone, and a deferred apply that had to be dropped hands its
|
||||
// staged values back here -- but only while the user is still on the area they
|
||||
// were staged for. Either way the values stay the user's to fix.
|
||||
AreaConfig area_edits_{};
|
||||
std::array<AreaConfig, AREA_COUNT> interference_area_values_{};
|
||||
std::array<AreaConfig, AREA_COUNT> detection_area_values_{};
|
||||
uint8_t area_id_{0xFF};
|
||||
bool area_id_set_{false};
|
||||
|
||||
// Which person owns each target_N slot, so a slot survives the module re-sorting its array.
|
||||
std::array<int32_t, MAX_TARGETS> slot_cluster_{};
|
||||
@@ -479,14 +318,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
|
||||
// The report handlers read these and drop anything a stopped stream still emits.
|
||||
bool target_display_enabled_{false};
|
||||
bool point_cloud_enabled_{false};
|
||||
bool area_presence_any_{false};
|
||||
bool area_write_in_flight_{false};
|
||||
bool work_mode_reported_{false};
|
||||
bool low_power_enabled_{false};
|
||||
bool low_power_reported_{false};
|
||||
bool deferred_apply_pending_{false};
|
||||
uint8_t pending_area_id_{0xFF};
|
||||
AreaConfig pending_area_updates_{};
|
||||
bool last_work_mode_valid_{false};
|
||||
bool last_work_mode_low_power_{false};
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AREA_ID,
|
||||
CONF_BUTTON,
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
DEVICE_CLASS_DURATION,
|
||||
ENTITY_CATEGORY_CONFIG,
|
||||
@@ -11,22 +9,14 @@ from esphome.const import (
|
||||
UNIT_MILLISECOND,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import LD6002BComponent, ld6002b_ns
|
||||
from ..const import (
|
||||
CONF_APPLY_AREA,
|
||||
CONF_AREA_CONFIG,
|
||||
CONF_HOLD_DELAY,
|
||||
CONF_LD6002B_ID,
|
||||
CONF_LOW_POWER_SLEEP_TIME,
|
||||
CONF_Z_MAX,
|
||||
CONF_Z_MIN,
|
||||
KEY_X_MAX,
|
||||
KEY_X_MIN,
|
||||
KEY_Y_MAX,
|
||||
KEY_Y_MIN,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["ld6002b"]
|
||||
@@ -61,83 +51,10 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(CONF_AREA_CONFIG): cv.Schema(
|
||||
{
|
||||
cv.Optional(KEY_X_MIN): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(KEY_X_MAX): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(KEY_Y_MIN): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(KEY_Y_MAX): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(CONF_Z_MIN): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
cv.Optional(CONF_Z_MAX): number.number_schema(
|
||||
LD6002BNumber,
|
||||
unit_of_measurement=UNIT_METER,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> ConfigType:
|
||||
if config.get(CONF_AREA_CONFIG) is None:
|
||||
return config
|
||||
|
||||
full_config = fv.full_config.get()
|
||||
hub_id = config[CONF_LD6002B_ID]
|
||||
|
||||
has_apply_area = any(
|
||||
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None
|
||||
for entry in full_config.get(CONF_BUTTON, [])
|
||||
)
|
||||
if not has_apply_area:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance",
|
||||
path=[CONF_AREA_CONFIG],
|
||||
)
|
||||
|
||||
has_area_id_select = any(
|
||||
entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None
|
||||
for entry in full_config.get("select", [])
|
||||
)
|
||||
if not has_area_id_select:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance",
|
||||
path=[CONF_AREA_CONFIG],
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_LD6002B_ID])
|
||||
|
||||
@@ -163,19 +80,3 @@ async def to_code(config):
|
||||
)
|
||||
await cg.register_parented(n, config[CONF_LD6002B_ID])
|
||||
cg.add(getattr(hub, setter)(n))
|
||||
|
||||
if area_config := config.get(CONF_AREA_CONFIG):
|
||||
for key, number_type, setter in (
|
||||
(KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"),
|
||||
(KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"),
|
||||
(KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"),
|
||||
(KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"),
|
||||
(CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"),
|
||||
(CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"),
|
||||
):
|
||||
if conf := area_config.get(key):
|
||||
n = await number.new_number(
|
||||
conf, number_type, min_value=-10, max_value=10, step=0.1
|
||||
)
|
||||
await cg.register_parented(n, config[CONF_LD6002B_ID])
|
||||
cg.add(getattr(hub, setter)(n))
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
|
||||
from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
|
||||
|
||||
from .. import LD6002BComponent, ld6002b_ns
|
||||
from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED
|
||||
@@ -11,16 +11,6 @@ DEPENDENCIES = ["ld6002b"]
|
||||
LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select)
|
||||
SelectType = ld6002b_ns.enum("SelectType", is_class=True)
|
||||
|
||||
AREA_ID_OPTIONS = [
|
||||
"interference_area_0",
|
||||
"interference_area_1",
|
||||
"interference_area_2",
|
||||
"interference_area_3",
|
||||
"detection_area_0",
|
||||
"detection_area_1",
|
||||
"detection_area_2",
|
||||
"detection_area_3",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -34,9 +24,6 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_INSTALLATION_MODE): select.select_schema(
|
||||
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
cv.Optional(CONF_AREA_ID): select.select_schema(
|
||||
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -60,7 +47,6 @@ SELECT_MAP = (
|
||||
"set_installation_select",
|
||||
["top", "side"],
|
||||
),
|
||||
(CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,18 +12,11 @@ from esphome.const import (
|
||||
|
||||
from . import LD6002BComponent
|
||||
from .const import (
|
||||
AREA_COUNT,
|
||||
CONF_CLUSTER_ID,
|
||||
CONF_DOPPLER_INDEX,
|
||||
CONF_LD6002B_ID,
|
||||
CONF_POINT_COUNT,
|
||||
CONF_Z,
|
||||
CONF_Z_MAX,
|
||||
CONF_Z_MIN,
|
||||
KEY_X_MAX,
|
||||
KEY_X_MIN,
|
||||
KEY_Y_MAX,
|
||||
KEY_Y_MIN,
|
||||
MAX_TARGETS,
|
||||
)
|
||||
|
||||
@@ -75,79 +68,20 @@ TARGET_SCHEMA = cv.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
AREA_SCHEMA = cv.Schema(
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(KEY_X_MIN): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(KEY_X_MAX): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(KEY_Y_MIN): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(KEY_Y_MAX): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_Z_MIN): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_Z_MAX): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# (config key, C++ setter axis) for the six bounds every area sensor block carries.
|
||||
_AREA_AXES = (
|
||||
(KEY_X_MIN, "x_min"),
|
||||
(KEY_X_MAX, "x_max"),
|
||||
(KEY_Y_MIN, "y_min"),
|
||||
(KEY_Y_MAX, "y_max"),
|
||||
(CONF_Z_MIN, "z_min"),
|
||||
(CONF_Z_MAX, "z_max"),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
|
||||
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
|
||||
.extend(
|
||||
{cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)}
|
||||
)
|
||||
.extend(
|
||||
{cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)}
|
||||
)
|
||||
)
|
||||
).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
@@ -178,11 +112,3 @@ async def to_code(config):
|
||||
if cluster_id_config := target_config.get(CONF_CLUSTER_ID):
|
||||
sens = await sensor.new_sensor(cluster_id_config)
|
||||
cg.add(hub.set_target_cluster_id_sensor(i, sens))
|
||||
|
||||
for kind in ("interference", "detection"):
|
||||
for i in range(AREA_COUNT):
|
||||
if area_config := config.get(f"{kind}_area_{i}"):
|
||||
for key, axis in _AREA_AXES:
|
||||
if axis_config := area_config.get(key):
|
||||
sens = await sensor.new_sensor(axis_config)
|
||||
cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens))
|
||||
@@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) {
|
||||
// downstream the value is used exactly like on ESP32.
|
||||
const int8_t raw = info->rssi;
|
||||
|
||||
memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE);
|
||||
memcpy(slot->mac, info->trans_addr, 6);
|
||||
slot->rssi = (raw > 20) ? static_cast<int8_t>(-raw) : raw;
|
||||
slot->addr_type = info->trans_addr_type;
|
||||
slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG;
|
||||
@@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() {
|
||||
ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC");
|
||||
}
|
||||
if (!have_unique_addr) {
|
||||
uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0};
|
||||
uint8_t wifi_mac[6] = {0};
|
||||
get_mac_address_raw(wifi_mac); // MSB-first
|
||||
// Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment
|
||||
// the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the
|
||||
@@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() {
|
||||
ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored");
|
||||
}
|
||||
}
|
||||
memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE);
|
||||
memcpy(this->ble_mac_, bt_addr.addr, 6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t {
|
||||
/// One scan report from the controller, decoded from the SDK's rw-task event
|
||||
/// (RSSI already sign-corrected).
|
||||
struct BLEScanReport {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first)
|
||||
int8_t rssi; // signed dBm (-127..+20)
|
||||
uint8_t mac[6]; // as the controller delivers it (LSB-first)
|
||||
int8_t rssi; // signed dBm (-127..+20)
|
||||
uint8_t addr_type;
|
||||
bool is_scan_response; // report is a scan response (active scan)
|
||||
bool scannable; // advertisement may be followed by a scan response
|
||||
@@ -138,7 +138,7 @@ class LN882HBLE final : public Component {
|
||||
// Reports rejected by the legacy-only filter (rw-task producer, main-task
|
||||
// consumer via exchange in loop()).
|
||||
std::atomic<uint16_t> rejected_reports_{0};
|
||||
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
|
||||
uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
bool enable_on_boot_{false};
|
||||
bool scanning_{false}; // controller scan running (re-entry guard for scan_start)
|
||||
|
||||
@@ -47,7 +47,7 @@ BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
|
||||
|
||||
# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty).
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
|
||||
"100ms", window_default="50ms"
|
||||
"100ms", window_default="50ms", supports_active=True
|
||||
)
|
||||
|
||||
|
||||
@@ -129,9 +129,6 @@ async def stop_scan_action_to_code(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
|
||||
cg.add_define("USE_LN882H_BLE_TRACKER")
|
||||
# Compiles the shared adv + scan-response merge (the LN controller
|
||||
# delivers the pair as separate reports).
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ln882h_ble_tracker.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -21,9 +22,6 @@ void LN882HBLETracker::setup() {
|
||||
// Receive the controller's scan reports; the controller queues them from the
|
||||
// rw task and delivers here on the main task.
|
||||
this->parent_->register_scan_listener(this);
|
||||
// Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_
|
||||
// is read at each delivery to decide unclaimed-device logging.
|
||||
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
|
||||
// scan_running_ check: an on_boot start_scan action (priority 600) runs
|
||||
// before this setup() (200) and enable_loop() is a no-op pre-setup — parking
|
||||
// the loop here would strand that already-running scan.
|
||||
@@ -74,11 +72,19 @@ void LN882HBLETracker::loop() {
|
||||
this->start_scan_();
|
||||
}
|
||||
}
|
||||
// Deliver held scannable advertisements whose scan response never arrived —
|
||||
// unmerged after the merger's timeout. Main-task only, like every merger call.
|
||||
// Flush pending scannable advertisements whose scan response never arrived
|
||||
// (device didn't answer / frame lost) — delivered unmerged after the timeout.
|
||||
// Main-task only, like every consumer of pending_adv_.
|
||||
const uint32_t now = millis();
|
||||
if (!this->merger_.empty())
|
||||
this->merger_.sweep(now);
|
||||
if (this->pending_count_ != 0) {
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
@@ -139,25 +145,129 @@ void LN882HBLETracker::dump_config() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adv/scan-response demux into the shared merger (ble_device_base): the LN
|
||||
// controller delivers the pair as separate reports; a scannable advertisement
|
||||
// is held until its scan response arrives and delivered as one merged frame.
|
||||
// Adv/scan-response demux with Bluedroid-style merge: the LN controller
|
||||
// delivers the pair as separate reports; a scannable advertisement is held
|
||||
// until its scan response arrives and delivered as one merged frame.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) {
|
||||
if (report.is_scan_response) {
|
||||
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
this->deliver_scan_rsp_(report);
|
||||
return;
|
||||
}
|
||||
// Stash only while the scan runs: after a one-shot stop the loop is
|
||||
// disabled and nothing would sweep the merger, so a late report would
|
||||
// disabled and nothing would sweep the table, so a late report would
|
||||
// surface minutes later as a fresh advertisement.
|
||||
if (this->scan_running_ && this->scan_active_ && report.scannable) {
|
||||
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis());
|
||||
this->stash_adv_(report);
|
||||
return;
|
||||
}
|
||||
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
|
||||
}
|
||||
|
||||
// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its
|
||||
// scan response.
|
||||
void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) {
|
||||
// One pass: find a same-device entry (deliver + reuse) while remembering the
|
||||
// first free slot as the fallback.
|
||||
PendingAdv *slot = nullptr;
|
||||
PendingAdv *free_slot = nullptr;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (!p.used) {
|
||||
if (free_slot == nullptr)
|
||||
free_slot = &p;
|
||||
continue;
|
||||
}
|
||||
if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
|
||||
// Same device advertised again before its scan response arrived — deliver
|
||||
// the previous advertisement (its scan response is not coming) and reuse
|
||||
// the slot, so no frame is ever lost.
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
slot = &p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = free_slot;
|
||||
if (slot == nullptr) {
|
||||
// Table full — degrade gracefully: deliver the advertisement unmerged.
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
slot->used = true;
|
||||
this->pending_count_++;
|
||||
memcpy(slot->mac, report.mac, 6);
|
||||
slot->addr_type = report.addr_type;
|
||||
slot->rssi = report.rssi;
|
||||
slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data);
|
||||
memcpy(slot->data, report.data, slot->data_len);
|
||||
slot->stored_ms = millis();
|
||||
}
|
||||
|
||||
// Scan response arrived: merge it with the pending advertisement from the same
|
||||
// device into ONE frame (ESP-IDF/Bluedroid semantics).
|
||||
void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) {
|
||||
// Fast-out on the empty table (loop()/flush use the same guard); this is
|
||||
// the hottest caller.
|
||||
if (this->pending_count_ != 0) {
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
|
||||
// Append in place: the slot is released on delivery, so its 62-byte
|
||||
// buffer (legacy adv + scan response) holds the merged frame directly.
|
||||
const uint8_t room = sizeof(p.data) - p.data_len;
|
||||
const uint8_t add = (report.data_len <= room) ? report.data_len : room;
|
||||
memcpy(p.data + p.data_len, report.data, add);
|
||||
p.used = false;
|
||||
this->pending_count_--;
|
||||
// The advertisement's RSSI, not the scan response's: every unmerged path
|
||||
// reports the advertisement's measurement, so a device's RSSI must not
|
||||
// jump between two measurements depending on merge timing.
|
||||
this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unmatched scan-response: goes out on the raw callback only (HA merges per
|
||||
// address); local listeners/triggers receive each advertisement exactly once
|
||||
// via the merged/plain path above.
|
||||
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true);
|
||||
}
|
||||
|
||||
void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint8_t data_len, bool raw_only) {
|
||||
// Raw callback (the raw-advertisement path). Both full advertisements and
|
||||
// unmatched scan responses (raw_only) are forwarded.
|
||||
if (this->raw_advertisement_callback_.is_set()) {
|
||||
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac),
|
||||
.data = data,
|
||||
.data_len = data_len,
|
||||
.rssi = rssi,
|
||||
.addr_type = addr_type};
|
||||
this->raw_advertisement_callback_.invoke(adv);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Scan-response-only frames are never parsed for local sensors/triggers.
|
||||
if (raw_only)
|
||||
return;
|
||||
ble_device_base::ESPBTDevice device;
|
||||
device.from_scan_result(mac, rssi, addr_type, data, data_len);
|
||||
// The listener list holds sensors AND this tracker's automation triggers
|
||||
// (the triggers are listeners, exactly like esp32_ble_tracker), so one
|
||||
// loop feeds both and ORs into `found`.
|
||||
bool found = false;
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->parse_device(device)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
|
||||
// it and the scan is one-shot (continuous scans would spam).
|
||||
if (!found && !this->scan_continuous_)
|
||||
this->discovered_log_.log_device(TAG, device);
|
||||
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -246,11 +356,29 @@ void LN882HBLETracker::stop_scan_() {
|
||||
// Close a scan period: deliver held advertisements whose scan response never
|
||||
// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock.
|
||||
void LN882HBLETracker::end_scan_period_(uint32_t now) {
|
||||
this->merger_.flush();
|
||||
this->dispatcher_.on_scan_end();
|
||||
this->flush_pending_adv_();
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
|
||||
// Deliver every held advertisement now (scan period/scan is ending): unmerged
|
||||
// delivery, same as the timeout path in loop(). Main-task only.
|
||||
void LN882HBLETracker::flush_pending_adv_() {
|
||||
if (this->pending_count_ == 0)
|
||||
return;
|
||||
for (auto &p : this->pending_adv_) {
|
||||
if (p.used) {
|
||||
p.used = false;
|
||||
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
|
||||
}
|
||||
}
|
||||
this->pending_count_ = 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ln882h_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/ble_device_base/scan_response_merger.h"
|
||||
#include "esphome/components/ln882h_ble/ln882h_ble.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -76,10 +75,12 @@ class LN882HBLETracker : public Component,
|
||||
|
||||
// ---- ble_device_base::BLEHub contract ----
|
||||
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
|
||||
this->dispatcher_.register_listener(listener);
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
this->listeners_.push_back(listener);
|
||||
#endif
|
||||
}
|
||||
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
|
||||
this->dispatcher_.set_raw_advertisement_callback(callback);
|
||||
this->raw_advertisement_callback_ = callback;
|
||||
}
|
||||
static constexpr ble_device_base::HubCapabilities get_capabilities() {
|
||||
// The LN882H controller supports active scanning; adv + scan response arrive
|
||||
@@ -90,8 +91,8 @@ class LN882HBLETracker : public Component,
|
||||
}
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
void get_adapter_mac(uint8_t out[6]) {
|
||||
uint8_t mac[6];
|
||||
this->parent_->get_mac_lsb_first(mac);
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = mac[5 - i];
|
||||
@@ -107,11 +108,27 @@ class LN882HBLETracker : public Component,
|
||||
void on_scan_report(const ln882h_ble::BLEScanReport &report) override;
|
||||
|
||||
protected:
|
||||
// Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into
|
||||
// one result before ESPHome sees it; the LN controller reports them separately):
|
||||
// a scannable advertisement is held here briefly, its scan response is appended
|
||||
// on arrival and the pair is delivered as ONE merged frame. Held entries whose
|
||||
// scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS.
|
||||
// All of this runs on the main task (the controller queue already crossed tasks),
|
||||
// so no locking is involved.
|
||||
void stash_adv_(const ln882h_ble::BLEScanReport &report);
|
||||
void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report);
|
||||
// Dispatch one (possibly merged) advertisement: the raw
|
||||
// callback, and — unless raw_only — parsing for listeners/triggers. raw_only
|
||||
// marks unmatched scan-response frames: forwarded on the raw callback only,
|
||||
// never to local sensors/triggers (HA merges per address).
|
||||
void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
|
||||
bool raw_only);
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
// Close a scan period: flush held advertisements (unmerged) BEFORE
|
||||
// on_scan_end fires, then re-anchor the period clock to `now`.
|
||||
void end_scan_period_(uint32_t now);
|
||||
void flush_pending_adv_();
|
||||
|
||||
bool scan_running_{false};
|
||||
bool scan_active_{false};
|
||||
@@ -130,13 +147,45 @@ class LN882HBLETracker : public Component,
|
||||
#endif
|
||||
uint32_t scan_start_time_{0};
|
||||
|
||||
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
|
||||
// All calls run on the main task (the controller queue already crossed
|
||||
// tasks); the merger is clocked by millis() throughout this tracker.
|
||||
ble_device_base::ScanResponseMerger merger_;
|
||||
ble_device_base::AdvDispatcher dispatcher_;
|
||||
// Pending scannable advertisements awaiting their scan response (active scan).
|
||||
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as
|
||||
// ESP-IDF delivers on ESP32. Main-task only.
|
||||
struct PendingAdv {
|
||||
bool used{false};
|
||||
uint8_t mac[6];
|
||||
uint8_t addr_type;
|
||||
int8_t rssi;
|
||||
uint8_t data_len; // <= sizeof(data)
|
||||
uint8_t data[62];
|
||||
uint32_t stored_ms;
|
||||
};
|
||||
// Sized for the unanswered case: a pair that IS answered normally matches
|
||||
// within one queue drain, so a slot is held for the full timeout only by
|
||||
// scannable devices that never reply. 8 concurrent such advertisers before
|
||||
// the merge degrades (frames still delivered, just unmerged) at ~80 B each.
|
||||
static constexpr size_t MAX_PENDING_ADV = 8;
|
||||
// On air a scan response follows its advertisement by T_IFS (150 µs) — the
|
||||
// timeout only covers HOST-side report queuing in rw_task under WiFi/BLE
|
||||
// coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin,
|
||||
// while staying below any device's re-advertising period.
|
||||
static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300;
|
||||
PendingAdv pending_adv_[MAX_PENDING_ADV];
|
||||
// Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table
|
||||
// in the common case (empty: passive scan, or every pair already matched).
|
||||
uint8_t pending_count_{0};
|
||||
|
||||
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
|
||||
|
||||
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Parsed-advertisement consumers registered through ble_device_base.
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiations.
|
||||
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
|
||||
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
|
||||
// in ble_device_base, identical output on every tracker backend. Guarded like
|
||||
// its only writer so a no-listener build does not carry an unused vector.
|
||||
ble_device_base::DiscoveredDeviceLog discovered_log_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ln882h_ble_tracker
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
CODEOWNERS = ["@crnjan"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "mitsubishi_cn105"
|
||||
|
||||
CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id"
|
||||
CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval"
|
||||
|
||||
mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN)
|
||||
|
||||
MitsubishiCN105Component = mitsubishi_ns.class_(
|
||||
"MitsubishiCN105Component",
|
||||
cg.Component,
|
||||
uart.UARTDevice,
|
||||
)
|
||||
|
||||
SetRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"SetRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Component),
|
||||
)
|
||||
|
||||
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"ClearRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Component),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MitsubishiCN105Component),
|
||||
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
|
||||
cv.Optional(
|
||||
CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s"
|
||||
): cv.update_interval,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID])
|
||||
cg.add(var.set_parent(parent))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add(
|
||||
var.set_telemetry_request_min_interval(
|
||||
config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component),
|
||||
cv.Required(CONF_TEMPERATURE): cv.templatable(
|
||||
cv.All(
|
||||
cv.temperature,
|
||||
cv.Range(min=8.0, max=39.5),
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
f"{DOMAIN}.set_remote_temperature",
|
||||
SetRemoteTemperatureAction,
|
||||
REMOTE_TEMPERATURE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def remote_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
|
||||
cg.add(var.set_temperature(temperature))
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
f"{DOMAIN}.clear_remote_temperature",
|
||||
ClearRemoteTemperatureAction,
|
||||
CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def clear_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "mitsubishi_cn105_component.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
template<typename... Ts>
|
||||
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, uart
|
||||
@@ -9,248 +7,126 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_SUPPORTED_SWING_MODES,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UART_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
from . import (
|
||||
CONF_MITSUBISHI_CN105_ID,
|
||||
DOMAIN,
|
||||
MITSUBISHI_CN105_DEVICE_SCHEMA,
|
||||
MitsubishiCN105Component,
|
||||
mitsubishi_ns,
|
||||
register_mitsubishi_cn105_device,
|
||||
)
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
DEPENDENCIES = ["uart"]
|
||||
AUTO_LOAD = ["climate"]
|
||||
CODEOWNERS = ["@crnjan"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Deprecated legacy climate-owned hub option. Remove in 2027.2.0.
|
||||
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval"
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id"
|
||||
|
||||
mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105")
|
||||
|
||||
MitsubishiCN105Climate = mitsubishi_ns.class_(
|
||||
"MitsubishiCN105Climate",
|
||||
climate.Climate,
|
||||
cg.Component,
|
||||
cg.Parented.template(MitsubishiCN105Component),
|
||||
uart.UARTDevice,
|
||||
)
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
LegacySetRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"LegacySetRemoteTemperatureAction",
|
||||
SetRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"SetRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Climate),
|
||||
)
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"LegacyClearRemoteTemperatureAction",
|
||||
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"ClearRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Climate),
|
||||
)
|
||||
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
def _has_top_level_hub_config() -> bool:
|
||||
return DOMAIN in (CORE.raw_config or {})
|
||||
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
|
||||
_LOGGER.warning(
|
||||
"Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is "
|
||||
"deprecated. Declare '%s:' and reference it with '%s:' instead. Will "
|
||||
"be removed in ESPHome 2027.2.0.",
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
CONF_MITSUBISHI_CN105_ID,
|
||||
)
|
||||
|
||||
# Add the hidden hub declaration only for legacy climate-owned configs,
|
||||
# so normal auto-ID resolution does not see it as a top-level hub.
|
||||
config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)(
|
||||
None
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
): validate_climate_swing_mode,
|
||||
}
|
||||
)
|
||||
|
||||
_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA)
|
||||
|
||||
# Hub options accepted in the legacy climate-owned configuration. When a
|
||||
# top-level hub exists, leaving these on the climate is always a migration
|
||||
# mistake and the generic schema error does not explain where they belong.
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
_LEGACY_HUB_KEYS = (
|
||||
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL,
|
||||
CONF_UART_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType:
|
||||
legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config]
|
||||
if not legacy_keys:
|
||||
return config
|
||||
|
||||
keys = ", ".join(f"'{key}'" for key in legacy_keys)
|
||||
message = f"{keys} must be moved under the top-level '{DOMAIN}:' block"
|
||||
if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys:
|
||||
message += (
|
||||
f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to "
|
||||
"'telemetry_request_min_interval' there"
|
||||
)
|
||||
raise cv.Invalid(message)
|
||||
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
_LEGACY_SCHEMA = (
|
||||
_BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA)
|
||||
CONFIG_SCHEMA = (
|
||||
climate.climate_schema(MitsubishiCN105Climate)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval,
|
||||
cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval,
|
||||
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
|
||||
cv.Optional(
|
||||
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s"
|
||||
): cv.update_interval,
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
): validate_climate_swing_mode,
|
||||
}
|
||||
)
|
||||
.add_extra(_prepare_legacy_hub_config)
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def CONFIG_SCHEMA(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
return _HUB_SCHEMA
|
||||
if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config():
|
||||
return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config))
|
||||
return _LEGACY_SCHEMA(config)
|
||||
|
||||
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
def _legacy_final_validate(config: ConfigType) -> ConfigType:
|
||||
if CONF_MITSUBISHI_CN105_ID in config:
|
||||
return config
|
||||
|
||||
return uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
uart.final_validate_device_schema(
|
||||
"mitsubishi_cn105",
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)(config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _legacy_final_validate
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate.new_climate(config)
|
||||
climate_config = config.copy()
|
||||
# update_interval configures the protocol hub, not the climate entity.
|
||||
climate_config.pop(CONF_UPDATE_INTERVAL, None)
|
||||
await cg.register_component(var, climate_config)
|
||||
if CONF_MITSUBISHI_CN105_ID in config:
|
||||
await register_mitsubishi_cn105_device(var, config)
|
||||
else:
|
||||
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
|
||||
parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID])
|
||||
await cg.register_component(parent, config)
|
||||
await uart.register_uart_device(parent, config)
|
||||
if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config:
|
||||
cg.add(
|
||||
parent.set_telemetry_request_min_interval(
|
||||
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
|
||||
)
|
||||
)
|
||||
cg.add(var.set_parent(parent))
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES]))
|
||||
cg.add(
|
||||
var.set_current_temperature_min_interval(
|
||||
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
cv.Required(CONF_TEMPERATURE): cv.templatable(
|
||||
cv.All(
|
||||
cv.temperature,
|
||||
cv.Range(min=8.0, max=39.5),
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
@automation.register_action(
|
||||
f"climate.{DOMAIN}.set_remote_temperature",
|
||||
LegacySetRemoteTemperatureAction,
|
||||
LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA,
|
||||
"climate.mitsubishi_cn105.set_remote_temperature",
|
||||
SetRemoteTemperatureAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
cv.Required(CONF_TEMPERATURE): cv.templatable(
|
||||
cv.All(
|
||||
cv.temperature,
|
||||
cv.Range(min=8.0, max=39.5),
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def legacy_remote_temperature_action_to_code(
|
||||
async def set_remote_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
_LOGGER.warning(
|
||||
"The 'climate.%s.set_remote_temperature' action is deprecated. Use "
|
||||
"'%s.set_remote_temperature' instead. It will be removed in ESPHome "
|
||||
"2027.2.0.",
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
)
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
|
||||
cg.add(var.set_temperature(temperature))
|
||||
|
||||
return var
|
||||
|
||||
|
||||
# Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
@automation.register_action(
|
||||
f"climate.{DOMAIN}.clear_remote_temperature",
|
||||
LegacyClearRemoteTemperatureAction,
|
||||
LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA,
|
||||
"climate.mitsubishi_cn105.clear_remote_temperature",
|
||||
ClearRemoteTemperatureAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def legacy_clear_temperature_action_to_code(
|
||||
async def clear_remote_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
_LOGGER.warning(
|
||||
"The 'climate.%s.clear_remote_temperature' action is deprecated. Use "
|
||||
"'%s.clear_remote_temperature' instead. It will be removed in ESPHome "
|
||||
"2027.2.0.",
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
)
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
@@ -1,10 +1,8 @@
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include "mitsubishi_cn105_properties.h"
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
@@ -12,6 +10,8 @@ static const char *const TAG = "mitsubishi_cn105.driver";
|
||||
|
||||
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
|
||||
|
||||
static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10;
|
||||
static constexpr size_t HEADER_LEN = 5;
|
||||
static constexpr uint8_t PREAMBLE = 0xFC;
|
||||
@@ -25,11 +25,91 @@ static constexpr std::array<uint8_t, 2> CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01};
|
||||
static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42;
|
||||
static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62;
|
||||
static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02;
|
||||
static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03;
|
||||
static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
|
||||
|
||||
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
|
||||
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
|
||||
|
||||
template<auto Unknown, size_t N> struct LookupMap {
|
||||
using value_type = decltype(Unknown);
|
||||
static constexpr auto UNKNOWN_VALUE = Unknown;
|
||||
const std::array<value_type, N> table;
|
||||
|
||||
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
|
||||
|
||||
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
|
||||
static_assert(N <= std::numeric_limits<uint8_t>::max());
|
||||
if (value == UNKNOWN_VALUE) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
|
||||
if (this->table[i] == value) {
|
||||
out = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr bool is_valid(value_type value) const {
|
||||
uint8_t raw;
|
||||
return reverse_lookup(value, raw);
|
||||
}
|
||||
};
|
||||
|
||||
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
|
||||
return LookupMap<Unknown, N>{std::to_array(values)};
|
||||
}
|
||||
|
||||
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::Mode::HEAT, // 0x01
|
||||
MitsubishiCN105::Mode::DRY, // 0x02
|
||||
MitsubishiCN105::Mode::COOL, // 0x03
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x05
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
|
||||
MitsubishiCN105::Mode::AUTO // 0x08
|
||||
});
|
||||
|
||||
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
|
||||
MitsubishiCN105::FanMode::AUTO, // 0x00
|
||||
MitsubishiCN105::FanMode::QUIET, // 0x01
|
||||
MitsubishiCN105::FanMode::SPEED_1, // 0x02
|
||||
MitsubishiCN105::FanMode::SPEED_2, // 0x03
|
||||
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::FanMode::SPEED_3, // 0x05
|
||||
MitsubishiCN105::FanMode::SPEED_4 // 0x06
|
||||
});
|
||||
|
||||
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::VaneMode::AUTO, // 0x00
|
||||
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
|
||||
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
|
||||
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
|
||||
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
|
||||
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
|
||||
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::VaneMode::SWING // 0x07
|
||||
});
|
||||
|
||||
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
|
||||
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
|
||||
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
|
||||
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
|
||||
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
|
||||
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
|
||||
MitsubishiCN105::WideVaneMode::SWING // 0x0C
|
||||
});
|
||||
|
||||
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
|
||||
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
|
||||
}
|
||||
@@ -43,6 +123,10 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
|
||||
return packet;
|
||||
}
|
||||
|
||||
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
|
||||
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
|
||||
}
|
||||
|
||||
static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD);
|
||||
|
||||
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
|
||||
@@ -145,8 +229,8 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
case State::STATUS_UPDATED: {
|
||||
if (this->pending_updates_.any() && this->is_status_initialized()) {
|
||||
this->set_state_(State::APPLYING_SETTINGS);
|
||||
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) {
|
||||
this->current_status_msg_type_ = STATUS_MSG_TELEMETRY;
|
||||
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
|
||||
this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP;
|
||||
this->set_state_(State::UPDATING_STATUS);
|
||||
} else {
|
||||
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
@@ -180,26 +264,26 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
}
|
||||
}
|
||||
|
||||
bool MitsubishiCN105::should_request_telemetry_() const {
|
||||
if (!this->is_telemetry_polling_enabled()) {
|
||||
bool MitsubishiCN105::should_request_room_temperature_() const {
|
||||
if (!this->is_room_temperature_enabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->last_telemetry_update_ms_.has_value()) {
|
||||
if (!this->last_room_temperature_update_ms_.has_value()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_;
|
||||
return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_;
|
||||
}
|
||||
|
||||
void MitsubishiCN105::send_packet_(std::span<const uint8_t> packet) {
|
||||
FrameParser::dump_buffer_vv("TX", packet.data(), packet.size());
|
||||
this->device_.write_array(packet.data(), packet.size());
|
||||
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
|
||||
FrameParser::dump_buffer_vv("TX", packet, len);
|
||||
this->device_.write_array(packet, len);
|
||||
this->operation_start_ms_ = get_loop_time_ms();
|
||||
}
|
||||
|
||||
void MitsubishiCN105::update_status_() {
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{this->current_status_msg_type_};
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {this->current_status_msg_type_};
|
||||
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
|
||||
}
|
||||
|
||||
@@ -243,7 +327,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
|
||||
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
|
||||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
|
||||
|
||||
if (this->is_telemetry_polling_enabled()) {
|
||||
if (this->is_room_temperature_enabled()) {
|
||||
changed |= previous.room_temperature != this->status_.room_temperature;
|
||||
}
|
||||
|
||||
@@ -251,22 +335,12 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
|
||||
}
|
||||
|
||||
bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) {
|
||||
Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_};
|
||||
switch (msg_type) {
|
||||
case STATUS_MSG_SETTINGS:
|
||||
if (!decoder.decode_settings(this->status_)) {
|
||||
ESP_LOGVV(TAG, "RX settings payload too short");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return this->parse_status_settings_(payload, len);
|
||||
|
||||
case STATUS_MSG_TELEMETRY:
|
||||
if (!decoder.decode_room_temperature(this->status_)) {
|
||||
ESP_LOGVV(TAG, "RX telemetry payload too short");
|
||||
return false;
|
||||
}
|
||||
this->last_telemetry_update_ms_ = get_loop_time_ms();
|
||||
return true;
|
||||
case STATUS_MSG_ROOM_TEMP:
|
||||
return this->parse_status_room_temperature_(payload, len);
|
||||
|
||||
default:
|
||||
ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type);
|
||||
@@ -274,6 +348,54 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay
|
||||
}
|
||||
}
|
||||
|
||||
bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) {
|
||||
if (len <= 10) {
|
||||
ESP_LOGVV(TAG, "RX settings payload too short");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
|
||||
this->status_.power_on = payload[2] != 0;
|
||||
}
|
||||
|
||||
this->use_temperature_encoding_b_ = payload[10] != 0;
|
||||
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
|
||||
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
|
||||
const bool i_see = payload[3] > 0x08;
|
||||
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
|
||||
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
|
||||
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
|
||||
}
|
||||
|
||||
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
|
||||
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
|
||||
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) {
|
||||
if (len <= 5) {
|
||||
ESP_LOGVV(TAG, "RX room temperature payload too short");
|
||||
return false;
|
||||
}
|
||||
|
||||
this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10);
|
||||
this->last_room_temperature_update_ms_ = get_loop_time_ms();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_remote_temperature(float temperature) {
|
||||
if (std::isnan(temperature)) {
|
||||
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
|
||||
@@ -292,12 +414,12 @@ void MitsubishiCN105::clear_remote_temperature() {
|
||||
|
||||
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
|
||||
this->remote_temperature_half_deg_ = temperature_half_deg;
|
||||
this->pending_updates_.set(Property::Temperature::Remote::ID);
|
||||
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_power(bool power_on) {
|
||||
this->status_.power_on = power_on;
|
||||
this->pending_updates_.set(Property::Power::ID);
|
||||
this->pending_updates_.set(UpdateFlag::POWER);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_target_temperature(float target_temperature) {
|
||||
@@ -306,42 +428,101 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
|
||||
return;
|
||||
}
|
||||
this->status_.target_temperature = target_temperature;
|
||||
this->pending_updates_.set(Property::Temperature::Target::ID);
|
||||
this->pending_updates_.set(UpdateFlag::TEMPERATURE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_mode(Mode mode) {
|
||||
if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) {
|
||||
ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast<uint8_t>(mode));
|
||||
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
|
||||
return;
|
||||
}
|
||||
this->status_.mode = mode;
|
||||
this->pending_updates_.set(UpdateFlag::MODE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
|
||||
if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) {
|
||||
ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
|
||||
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
|
||||
return;
|
||||
}
|
||||
this->status_.fan_mode = fan_mode;
|
||||
this->pending_updates_.set(UpdateFlag::FAN);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
|
||||
if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) {
|
||||
ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
|
||||
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
|
||||
return;
|
||||
}
|
||||
this->status_.vane_mode = vane_mode;
|
||||
this->pending_updates_.set(UpdateFlag::VANE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
|
||||
if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) {
|
||||
ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
|
||||
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
|
||||
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
|
||||
return;
|
||||
}
|
||||
this->status_.wide_vane_mode = wide_vane_mode;
|
||||
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::apply_settings_() {
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
|
||||
Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_};
|
||||
|
||||
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
|
||||
if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) {
|
||||
encoder.encode_remote_temperature(this->remote_temperature_half_deg_);
|
||||
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
|
||||
payload[0] = 0x07;
|
||||
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
|
||||
payload[3] = 0x80;
|
||||
} else {
|
||||
payload[1] = 0x01;
|
||||
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
|
||||
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
|
||||
}
|
||||
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
|
||||
} else {
|
||||
encoder.encode_settings(this->status_);
|
||||
payload[0] = 0x01;
|
||||
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
|
||||
payload[1] |= 0x01;
|
||||
payload[3] = this->status_.power_on ? 0x01 : 0x00;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
|
||||
payload[1] |= 0x04;
|
||||
if (this->use_temperature_encoding_b_) {
|
||||
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
|
||||
} else {
|
||||
payload[5] =
|
||||
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
|
||||
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
|
||||
payload[1] |= 0x02;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
|
||||
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
|
||||
payload[1] |= 0x08;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
|
||||
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
|
||||
payload[1] |= 0x10;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
|
||||
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
|
||||
payload[2] |= 0x01;
|
||||
if (this->set_wide_vane_high_bit_) {
|
||||
payload[13] |= 0x80;
|
||||
}
|
||||
}
|
||||
|
||||
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
|
||||
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
|
||||
}
|
||||
|
||||
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/finite_set_mask.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/finite_set_mask.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
@@ -72,16 +70,16 @@ class MitsubishiCN105 {
|
||||
uint32_t get_update_interval() const { return this->update_interval_ms_; }
|
||||
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
|
||||
|
||||
uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; }
|
||||
bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; }
|
||||
void set_telemetry_request_min_interval(uint32_t interval_ms) {
|
||||
this->telemetry_request_min_interval_ms_ = interval_ms;
|
||||
uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; }
|
||||
bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; }
|
||||
void set_room_temperature_min_interval(uint32_t interval_ms) {
|
||||
this->room_temperature_min_interval_ms_ = interval_ms;
|
||||
}
|
||||
|
||||
const Status &status() const { return this->status_; }
|
||||
bool is_status_initialized() const {
|
||||
return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature)
|
||||
: !std::isnan(this->status_.target_temperature);
|
||||
return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature)
|
||||
: !std::isnan(this->status_.target_temperature);
|
||||
}
|
||||
|
||||
void set_power(bool power_on);
|
||||
@@ -122,64 +120,58 @@ class MitsubishiCN105 {
|
||||
uint8_t read_pos_{0};
|
||||
};
|
||||
|
||||
enum class PropertyId : uint8_t {
|
||||
enum class UpdateFlag : uint8_t {
|
||||
TEMPERATURE = 0,
|
||||
POWER = 1,
|
||||
MODE = 2,
|
||||
FAN = 3,
|
||||
VANE = 4,
|
||||
WIDE_VANE = 5,
|
||||
REMOTE_TEMPERATURE = 6
|
||||
REMOTE_TEMPERATURE = 6,
|
||||
};
|
||||
|
||||
struct UpdateFlags {
|
||||
void set(PropertyId id) { this->mask_.insert(id); }
|
||||
void clear(PropertyId id) { this->mask_.erase(id); }
|
||||
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
|
||||
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
|
||||
bool any() const { return !this->mask_.empty(); }
|
||||
bool contains(PropertyId id) const { return this->mask_.count(id); }
|
||||
bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); }
|
||||
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
|
||||
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
|
||||
|
||||
protected:
|
||||
using Mask =
|
||||
FiniteSetMask<PropertyId, DefaultBitPolicy<PropertyId, static_cast<int>(PropertyId::REMOTE_TEMPERATURE) + 1>>;
|
||||
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
|
||||
|
||||
Mask mask_;
|
||||
};
|
||||
|
||||
struct PropertyContext {
|
||||
bool use_temperature_encoding_b{false};
|
||||
bool set_wide_vane_high_bit{false};
|
||||
};
|
||||
|
||||
friend struct Property;
|
||||
|
||||
void set_state_(State new_state);
|
||||
void did_transition_(State to);
|
||||
bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len);
|
||||
bool process_status_packet_(const uint8_t *payload, size_t len);
|
||||
bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len);
|
||||
void send_packet_(std::span<const uint8_t> packet);
|
||||
bool parse_status_settings_(const uint8_t *payload, size_t len);
|
||||
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
|
||||
void send_packet_(const uint8_t *packet, size_t len);
|
||||
void update_status_();
|
||||
bool should_request_telemetry_() const;
|
||||
bool should_request_room_temperature_() const;
|
||||
void apply_settings_();
|
||||
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
|
||||
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
|
||||
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
|
||||
static bool should_transition(State from, State to);
|
||||
static const LogString *state_to_string(State state);
|
||||
|
||||
uart::UARTDevice &device_;
|
||||
// Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted.
|
||||
// Remove legacy note in 2027.2.0.
|
||||
uint32_t update_interval_ms_{1000};
|
||||
uint32_t status_update_wait_credit_ms_{0};
|
||||
uint32_t operation_start_ms_{0};
|
||||
// Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is
|
||||
// omitted. Remove legacy note in 2027.2.0.
|
||||
uint32_t telemetry_request_min_interval_ms_{60000};
|
||||
std::optional<uint32_t> last_telemetry_update_ms_;
|
||||
uint32_t room_temperature_min_interval_ms_{60000};
|
||||
std::optional<uint32_t> last_room_temperature_update_ms_;
|
||||
Status status_{};
|
||||
State state_{State::NOT_CONNECTED};
|
||||
UpdateFlags pending_updates_;
|
||||
PropertyContext property_context_;
|
||||
bool use_temperature_encoding_b_{false};
|
||||
bool set_wide_vane_high_bit_{false};
|
||||
FrameParser frame_parser_;
|
||||
uint8_t current_status_msg_type_{0};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#include <cinttypes>
|
||||
#include "mitsubishi_cn105_climate.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
@@ -50,11 +50,25 @@ static constexpr std::optional<Left> reverse_map_lookup(const std::array<std::pa
|
||||
return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt;
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); }
|
||||
void MitsubishiCN105Climate::dump_config() {
|
||||
LOG_CLIMATE("", "Mitsubishi CN105 Climate", this);
|
||||
if (this->hp_.is_room_temperature_enabled()) {
|
||||
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
|
||||
this->hp_.get_room_temperature_min_interval());
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Update interval: %" PRIu32 " ms\n"
|
||||
" UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u",
|
||||
this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(),
|
||||
LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits());
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::setup() {
|
||||
this->parent_->add_on_status_callback([this]() { this->apply_values_(); });
|
||||
if (this->parent_->is_status_initialized()) {
|
||||
void MitsubishiCN105Climate::setup() { this->hp_.initialize(); }
|
||||
|
||||
void MitsubishiCN105Climate::loop() {
|
||||
if (this->hp_.update()) {
|
||||
this->apply_values_();
|
||||
}
|
||||
}
|
||||
@@ -76,7 +90,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
traits.set_visual_max_temperature(31.0f);
|
||||
traits.set_visual_temperature_step(1.0f);
|
||||
|
||||
if (this->parent_->is_telemetry_polling_enabled()) {
|
||||
if (this->hp_.is_room_temperature_enabled()) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
traits.set_visual_current_temperature_step(0.5f);
|
||||
}
|
||||
@@ -86,20 +100,20 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
|
||||
void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
if (const auto target_temperature = call.get_target_temperature()) {
|
||||
this->parent_->set_target_temperature(*target_temperature);
|
||||
this->hp_.set_target_temperature(*target_temperature);
|
||||
}
|
||||
|
||||
if (const auto mode = call.get_mode()) {
|
||||
if (*mode == climate::CLIMATE_MODE_OFF) {
|
||||
this->parent_->set_power(false);
|
||||
this->hp_.set_power(false);
|
||||
} else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) {
|
||||
this->parent_->set_power(true);
|
||||
this->parent_->set_mode(*mapped);
|
||||
this->hp_.set_power(true);
|
||||
this->hp_.set_mode(*mapped);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) {
|
||||
this->parent_->set_fan_mode(*fan_mode);
|
||||
this->hp_.set_fan_mode(*fan_mode);
|
||||
}
|
||||
|
||||
if (const auto swing_mode = call.get_swing_mode()) {
|
||||
@@ -126,22 +140,24 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
this->parent_->set_vane_mode(vane);
|
||||
this->hp_.set_vane_mode(vane);
|
||||
}
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
this->parent_->set_wide_vane_mode(wide);
|
||||
this->hp_.set_wide_vane_mode(wide);
|
||||
}
|
||||
}
|
||||
|
||||
this->parent_->publish_status();
|
||||
if (this->hp_.is_status_initialized()) {
|
||||
this->apply_values_();
|
||||
}
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::apply_values_() {
|
||||
const auto &status = this->parent_->status();
|
||||
const auto &status = this->hp_.status();
|
||||
|
||||
this->target_temperature = status.target_temperature;
|
||||
|
||||
if (this->parent_->is_telemetry_polling_enabled()) {
|
||||
if (this->hp_.is_room_temperature_enabled()) {
|
||||
this->current_temperature = status.room_temperature;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "mitsubishi_cn105_component.h"
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
|
||||
class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice {
|
||||
public:
|
||||
explicit MitsubishiCN105Climate() : hp_(*this) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
climate::ClimateTraits traits() override;
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
|
||||
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
|
||||
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
|
||||
|
||||
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
|
||||
|
||||
void set_supported_swing_mode(climate::ClimateSwingMode mode);
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->parent_->clear_remote_temperature(); }
|
||||
|
||||
protected:
|
||||
void apply_values_();
|
||||
|
||||
MitsubishiCN105 hp_;
|
||||
climate::ClimateSwingModeMask supported_swing_modes_{};
|
||||
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
|
||||
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
|
||||
};
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
|
||||
};
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#include "mitsubishi_cn105_component.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
static const char *const TAG = "mitsubishi_cn105";
|
||||
|
||||
void MitsubishiCN105Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Mitsubishi CN105:");
|
||||
if (this->hp_.is_telemetry_polling_enabled()) {
|
||||
ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms",
|
||||
this->hp_.get_telemetry_request_min_interval());
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Update interval: %" PRIu32 " ms\n"
|
||||
" UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u",
|
||||
this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(),
|
||||
LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits());
|
||||
}
|
||||
|
||||
void MitsubishiCN105Component::setup() { this->hp_.initialize(); }
|
||||
|
||||
void MitsubishiCN105Component::loop() {
|
||||
if (this->hp_.update()) {
|
||||
this->status_callback_.call();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -1,52 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
explicit MitsubishiCN105Component() : hp_(*this) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
|
||||
void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); }
|
||||
|
||||
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
|
||||
|
||||
void set_power(bool power_on) { this->hp_.set_power(power_on); }
|
||||
void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); }
|
||||
void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); }
|
||||
void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); }
|
||||
void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); }
|
||||
void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); }
|
||||
|
||||
const MitsubishiCN105::Status &status() const { return this->hp_.status(); }
|
||||
bool is_status_initialized() const { return this->hp_.is_status_initialized(); }
|
||||
bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); }
|
||||
|
||||
template<typename F> void add_on_status_callback(F &&callback) {
|
||||
this->status_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void publish_status() {
|
||||
if (this->is_status_initialized()) {
|
||||
this->status_callback_.call();
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
MitsubishiCN105 hp_;
|
||||
CallbackManager<void()> status_callback_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -1,302 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
template<auto Unknown, size_t N> struct LookupMap {
|
||||
using value_type = decltype(Unknown);
|
||||
const std::array<value_type, N> table;
|
||||
|
||||
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; }
|
||||
|
||||
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
|
||||
static_assert(N <= std::numeric_limits<uint8_t>::max());
|
||||
if (value == Unknown) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
|
||||
if (this->table[i] == value) {
|
||||
out = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
|
||||
return LookupMap<Unknown, N>{std::to_array(values)};
|
||||
}
|
||||
|
||||
struct Property {
|
||||
using PropertyId = MitsubishiCN105::PropertyId;
|
||||
using Status = MitsubishiCN105::Status;
|
||||
using PropertyContext = MitsubishiCN105::PropertyContext;
|
||||
|
||||
struct Power {
|
||||
static constexpr auto ID = PropertyId::POWER;
|
||||
|
||||
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
|
||||
|
||||
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
|
||||
status.power_on = payload[2] != 0;
|
||||
}
|
||||
|
||||
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
|
||||
payload[1] |= 0x01;
|
||||
payload[3] = status.power_on ? 0x01 : 0x00;
|
||||
}
|
||||
};
|
||||
|
||||
struct Temperature {
|
||||
struct Target {
|
||||
static constexpr auto ID = PropertyId::TEMPERATURE;
|
||||
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
|
||||
|
||||
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
|
||||
ctx.use_temperature_encoding_b = payload[10] != 0;
|
||||
}
|
||||
|
||||
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
|
||||
status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
|
||||
}
|
||||
|
||||
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
|
||||
payload[1] |= 0x04;
|
||||
if (ctx.use_temperature_encoding_b) {
|
||||
payload[14] = static_cast<uint8_t>(std::round(status.target_temperature * 2.0f) + 128);
|
||||
} else {
|
||||
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct Room {
|
||||
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
|
||||
|
||||
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
|
||||
status.room_temperature = Temperature::decode(payload[2], payload[5], 10);
|
||||
}
|
||||
};
|
||||
|
||||
struct Remote {
|
||||
static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE;
|
||||
|
||||
static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) {
|
||||
if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) {
|
||||
payload[3] = 0x80;
|
||||
} else {
|
||||
payload[1] = 0x01;
|
||||
payload[2] = static_cast<uint8_t>(remote_temperature_half_deg - 16);
|
||||
payload[3] = static_cast<uint8_t>(remote_temperature_half_deg + 128);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected:
|
||||
static constexpr float decode(int temp_a, int temp_b, int delta) {
|
||||
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
|
||||
}
|
||||
};
|
||||
|
||||
template<typename Derived, auto Field> struct Lookup {
|
||||
using Value = std::remove_cvref_t<decltype(std::declval<Status>().*Field)>;
|
||||
|
||||
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
|
||||
|
||||
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
|
||||
status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx));
|
||||
}
|
||||
|
||||
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
|
||||
uint8_t raw;
|
||||
if (Derived::MAP.reverse_lookup(status.*Field, raw)) {
|
||||
Derived::encode_raw(payload, raw, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Mask> static bool validate_and_set(Value value, Status &status, Mask &mask) {
|
||||
uint8_t raw;
|
||||
if (!Derived::MAP.reverse_lookup(value, raw)) {
|
||||
return false;
|
||||
}
|
||||
status.*Field = value;
|
||||
mask.set(Derived::ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
friend Derived;
|
||||
constexpr Lookup() = default;
|
||||
};
|
||||
|
||||
struct Mode : Lookup<Mode, &Status::mode> {
|
||||
static constexpr auto ID = PropertyId::MODE;
|
||||
static constexpr auto MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::Mode::HEAT, // 0x01
|
||||
MitsubishiCN105::Mode::DRY, // 0x02
|
||||
MitsubishiCN105::Mode::COOL, // 0x03
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x05
|
||||
MitsubishiCN105::Mode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
|
||||
MitsubishiCN105::Mode::AUTO // 0x08
|
||||
});
|
||||
|
||||
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) {
|
||||
const bool i_see = payload[3] > 0x08;
|
||||
return payload[3] - (i_see ? 0x08 : 0);
|
||||
}
|
||||
|
||||
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
|
||||
payload[1] |= 0x02;
|
||||
payload[4] = raw;
|
||||
}
|
||||
};
|
||||
|
||||
struct FanMode : Lookup<FanMode, &Status::fan_mode> {
|
||||
static constexpr auto ID = PropertyId::FAN;
|
||||
static constexpr auto MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
|
||||
MitsubishiCN105::FanMode::AUTO, // 0x00
|
||||
MitsubishiCN105::FanMode::QUIET, // 0x01
|
||||
MitsubishiCN105::FanMode::SPEED_1, // 0x02
|
||||
MitsubishiCN105::FanMode::SPEED_2, // 0x03
|
||||
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
|
||||
MitsubishiCN105::FanMode::SPEED_3, // 0x05
|
||||
MitsubishiCN105::FanMode::SPEED_4 // 0x06
|
||||
});
|
||||
|
||||
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; }
|
||||
|
||||
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
|
||||
payload[1] |= 0x08;
|
||||
payload[6] = raw;
|
||||
}
|
||||
};
|
||||
|
||||
struct VaneMode : Lookup<VaneMode, &Status::vane_mode> {
|
||||
static constexpr auto ID = PropertyId::VANE;
|
||||
static constexpr auto MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::VaneMode::AUTO, // 0x00
|
||||
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
|
||||
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
|
||||
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
|
||||
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
|
||||
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
|
||||
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::VaneMode::SWING // 0x07
|
||||
});
|
||||
|
||||
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; }
|
||||
|
||||
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
|
||||
payload[1] |= 0x10;
|
||||
payload[7] = raw;
|
||||
}
|
||||
};
|
||||
|
||||
struct WideVaneMode : Lookup<WideVaneMode, &Status::wide_vane_mode> {
|
||||
static constexpr auto ID = PropertyId::WIDE_VANE;
|
||||
static constexpr auto MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
|
||||
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
|
||||
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
|
||||
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
|
||||
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
|
||||
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
|
||||
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
|
||||
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
|
||||
MitsubishiCN105::WideVaneMode::SWING // 0x0C
|
||||
});
|
||||
|
||||
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
|
||||
ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80;
|
||||
}
|
||||
|
||||
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; }
|
||||
|
||||
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) {
|
||||
payload[2] |= 0x01;
|
||||
payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw;
|
||||
}
|
||||
};
|
||||
|
||||
template<typename Mask> struct Decoder {
|
||||
const std::span<const uint8_t> payload;
|
||||
PropertyContext &context;
|
||||
const Mask &pending_writes;
|
||||
|
||||
bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) {
|
||||
if (this->payload.size() <= 10) {
|
||||
return false;
|
||||
}
|
||||
this->decode_<Power, Temperature::Target, Mode, FanMode, VaneMode, WideVaneMode>(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) {
|
||||
if (this->payload.size() <= 5) {
|
||||
return false;
|
||||
}
|
||||
this->decode_<Temperature::Room>(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
template<typename T, typename Out> ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) {
|
||||
T::decode_context(this->context, this->payload.data());
|
||||
if constexpr (requires { T::ID; }) {
|
||||
if (this->pending_writes.contains(T::ID)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
T::decode(out, this->payload.data(), this->context);
|
||||
}
|
||||
|
||||
template<typename... T, typename Out> void ESPHOME_ALWAYS_INLINE decode_(Out &out) {
|
||||
(this->decode_one_<T>(out), ...);
|
||||
}
|
||||
};
|
||||
|
||||
template<typename Mask> struct Encoder {
|
||||
uint8_t *payload;
|
||||
const PropertyContext &context;
|
||||
Mask &pending_writes;
|
||||
|
||||
void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) {
|
||||
this->payload[0] = 0x01;
|
||||
this->encode_and_clear_<Power, Temperature::Target, WideVaneMode, VaneMode, Mode, FanMode>(status);
|
||||
}
|
||||
|
||||
void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) {
|
||||
this->payload[0] = 0x07;
|
||||
this->encode_and_clear_<Temperature::Remote>(remote_temperature_half_deg);
|
||||
}
|
||||
|
||||
protected:
|
||||
template<typename... T, typename In> void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) {
|
||||
(this->encode_one_<T>(in), ...);
|
||||
(this->pending_writes.clear(T::ID), ...);
|
||||
}
|
||||
|
||||
template<typename T, typename In> void encode_one_(const In &in) {
|
||||
if (this->pending_writes.contains(T::ID)) {
|
||||
T::encode(this->payload, in, this->context);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -1,47 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
MITSUBISHI_CN105_DEVICE_SCHEMA,
|
||||
MitsubishiCN105Component,
|
||||
mitsubishi_ns,
|
||||
register_mitsubishi_cn105_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["mitsubishi_cn105"]
|
||||
|
||||
CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction"
|
||||
|
||||
# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp.
|
||||
VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"]
|
||||
|
||||
MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_(
|
||||
"MitsubishiCN105VerticalVaneDirectionSelect",
|
||||
select.Select,
|
||||
cg.Component,
|
||||
cg.Parented.template(MitsubishiCN105Component),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema(
|
||||
MitsubishiCN105VerticalVaneDirectionSelect,
|
||||
icon="mdi:arrow-up-down",
|
||||
),
|
||||
}
|
||||
).extend(MITSUBISHI_CN105_DEVICE_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION):
|
||||
var = cg.new_Pvariable(vertical_vane_direction[CONF_ID])
|
||||
await cg.register_component(var, vertical_vane_direction)
|
||||
await select.register_select(
|
||||
var,
|
||||
vertical_vane_direction,
|
||||
options=VERTICAL_VANE_DIRECTIONS,
|
||||
)
|
||||
await register_mitsubishi_cn105_device(var, config)
|
||||
@@ -1,39 +0,0 @@
|
||||
#include "mitsubishi_cn105_vane_select_vertical.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py.
|
||||
// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based
|
||||
// Select API, so Python option order and this array must stay aligned.
|
||||
static constexpr std::array VALUES{
|
||||
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2,
|
||||
MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5,
|
||||
MitsubishiCN105::VaneMode::SWING,
|
||||
};
|
||||
|
||||
void MitsubishiCN105VerticalVaneDirectionSelect::setup() {
|
||||
this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); });
|
||||
if (this->parent_->is_status_initialized()) {
|
||||
this->publish_vane_state(this->parent_->status().vane_mode);
|
||||
}
|
||||
}
|
||||
|
||||
void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) {
|
||||
if (index < VALUES.size()) {
|
||||
this->parent_->set_vane_mode(VALUES[index]);
|
||||
this->parent_->publish_status();
|
||||
}
|
||||
}
|
||||
|
||||
void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) {
|
||||
for (size_t i = 0; i < VALUES.size(); ++i) {
|
||||
if (VALUES[i] == mode) {
|
||||
this->publish_state(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -1,21 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "../mitsubishi_cn105_component.h"
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void setup() override;
|
||||
void publish_vane_state(MitsubishiCN105::VaneMode mode);
|
||||
|
||||
protected:
|
||||
void control(size_t index) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -28,7 +28,6 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000
|
||||
MAX_NUM_OF_COILS_TO_WRITE = 1968
|
||||
MAX_NUM_OF_REGISTERS_TO_READ = 125
|
||||
MAX_NUM_OF_REGISTERS_TO_WRITE = 123
|
||||
MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121
|
||||
|
||||
modbus_ns = cg.esphome_ns.namespace("modbus")
|
||||
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
#include "modbus.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -381,9 +378,10 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) {
|
||||
if (!helpers::address_range_fits(start_address, count)) {
|
||||
ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count);
|
||||
ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) {
|
||||
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
|
||||
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
|
||||
number_of_registers);
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
return std::nullopt;
|
||||
@@ -396,11 +394,6 @@ static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2;
|
||||
static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5;
|
||||
// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1).
|
||||
static constexpr size_t READ_WRITE_VALUES_OFFSET = 9;
|
||||
// A coil write (FC 0x0F) is function(1) + start(2) + quantity(2) + byte count(1) + packed bits. The largest
|
||||
// one (MAX_NUM_OF_COILS_TO_WRITE coils) must fit the received request PDU, so the value subspan taken at
|
||||
// WRITE_MULTIPLE_VALUES_OFFSET can never run past it.
|
||||
static_assert(1 + WRITE_MULTIPLE_VALUES_OFFSET + packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE) <= MAX_PDU_SIZE,
|
||||
"the largest FC 0x0F coil write must fit within MAX_PDU_SIZE");
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
RegisterValues ®isters) {
|
||||
@@ -420,59 +413,13 @@ ResponseStatus ModbusServerHub::parse_write_multiple_(std::span<const uint8_t> d
|
||||
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes);
|
||||
return ExceptionCode::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
if (ResponseStatus status = this->check_address_range_(start_address, number_of_registers); status.has_value()) {
|
||||
if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) {
|
||||
return status;
|
||||
}
|
||||
this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities,
|
||||
const LogString *entity_name, uint16_t &start_address,
|
||||
uint16_t &count) {
|
||||
// Every read request is start address(2) + quantity(2); only the protocol ceiling differs per function
|
||||
// code, so registers and coils/discrete inputs validate through here and cannot drift apart.
|
||||
start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
count = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
if (count == 0 || count > max_entities) {
|
||||
ESP_LOGW(TAG, "Invalid number of %s %" PRIu16, LOG_STR_ARG(entity_name), count);
|
||||
return ExceptionCode::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
return this->check_address_range_(start_address, count);
|
||||
}
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
bool &value) {
|
||||
start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
const uint16_t raw_value = helpers::get_data<uint16_t>(data.data(), WRITE_SINGLE_VALUES_OFFSET);
|
||||
if (raw_value != 0xFF00 && raw_value != 0x0000) {
|
||||
ESP_LOGW(TAG, "Invalid coil value 0x%04X", raw_value);
|
||||
return ExceptionCode::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
// No range check needed: one coil can never push start_address + 1 past the address space.
|
||||
value = raw_value == 0xFF00;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ResponseStatus ModbusServerHub::parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
uint16_t &count, std::span<const uint8_t> &packed_bytes) {
|
||||
start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
const uint16_t number_of_bits = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
const uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 4);
|
||||
if (number_of_bits == 0 || number_of_bits > MAX_NUM_OF_COILS_TO_WRITE ||
|
||||
packed_bit_bytes(number_of_bits) != number_of_bytes) {
|
||||
ESP_LOGW(TAG, "Invalid number of coils %" PRIu16 " or bytes %" PRIu8, number_of_bits, number_of_bytes);
|
||||
return ExceptionCode::ILLEGAL_DATA_VALUE;
|
||||
}
|
||||
if (ResponseStatus status = this->check_address_range_(start_address, number_of_bits); status.has_value()) {
|
||||
return status;
|
||||
}
|
||||
count = number_of_bits;
|
||||
// coil values follow start(2) + quantity(2) + byte count(1)
|
||||
packed_bytes = data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, RegisterValues ®isters) {
|
||||
for (size_t offset = 0; offset + 1 < values.size(); offset += 2) {
|
||||
registers.push_back(helpers::get_data<uint16_t>(values.data(), offset));
|
||||
@@ -480,16 +427,11 @@ void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, Regis
|
||||
}
|
||||
|
||||
void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data) {
|
||||
// Broadcasts are only meaningful for writes and are never answered (Modbus 4.1 / 6.12), so an unsupported
|
||||
// function code or a validation failure is silently dropped instead of replying with an exception. Both
|
||||
// register writes (FC 0x06/0x10) and coil writes (FC 0x05/0x0F) are broadcastable by spec, and each shares
|
||||
// its parser with the addressed path so a broadcast is validated exactly as the unicast form would be.
|
||||
// Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an
|
||||
// unsupported function code or a validation failure is silently dropped instead of replying with an exception.
|
||||
// Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet.
|
||||
uint16_t start_address;
|
||||
RegisterValues registers;
|
||||
uint16_t coil_count = 0;
|
||||
std::span<const uint8_t> packed_bytes;
|
||||
uint8_t single_bit = 0; // backs packed_bytes for a single-coil write, so it must outlive the loop below
|
||||
bool coils = false;
|
||||
ResponseStatus status;
|
||||
switch (static_cast<FunctionCode>(function_code)) {
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
@@ -498,19 +440,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
status = this->parse_write_multiple_(data, start_address, registers);
|
||||
break;
|
||||
case FunctionCode::WRITE_SINGLE_COIL: {
|
||||
coils = true;
|
||||
bool value = false;
|
||||
status = this->parse_write_single_coil_(data, start_address, value);
|
||||
single_bit = value ? 0x01 : 0x00;
|
||||
coil_count = 1;
|
||||
packed_bytes = std::span<const uint8_t>(&single_bit, 1);
|
||||
break;
|
||||
}
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
coils = true;
|
||||
status = this->parse_write_multiple_coils_(data, start_address, coil_count, packed_bytes);
|
||||
break;
|
||||
default:
|
||||
// Reads and read/write require a reply, so they are not valid as broadcasts.
|
||||
ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code);
|
||||
@@ -523,12 +452,8 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
|
||||
// per-device outcome at V, and warn if the write reached nobody at all.
|
||||
bool accepted = false;
|
||||
for (auto *device : this->devices_) {
|
||||
// Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need
|
||||
// to: the hub owns the difference, which is only that no reply is ever sent.
|
||||
const ResponseStatus device_status =
|
||||
coils ? device->on_write_coils(start_address, PackedBits(packed_bytes, coil_count))
|
||||
: device->on_write_registers(start_address, registers);
|
||||
if (device_status.has_value()) {
|
||||
if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers);
|
||||
device_status.has_value()) {
|
||||
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
|
||||
static_cast<uint8_t>(device_status.value()));
|
||||
} else {
|
||||
@@ -536,19 +461,15 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
|
||||
}
|
||||
}
|
||||
if (!accepted && !this->devices_.empty()) {
|
||||
const uint16_t entity_count = coils ? coil_count : static_cast<uint16_t>(registers.size());
|
||||
const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers");
|
||||
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
|
||||
// repeats forever, so warning per frame would flood the log.
|
||||
const uint32_t now = millis();
|
||||
if (this->last_unaccepted_broadcast_warn_ == 0 ||
|
||||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
|
||||
this->last_unaccepted_broadcast_warn_ = now;
|
||||
ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
|
||||
LOG_STR_ARG(entity_name), start_address);
|
||||
ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
|
||||
LOG_STR_ARG(entity_name), start_address);
|
||||
ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +479,8 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu
|
||||
std::span<uint8_t> response_buffer, uint16_t &response_len) {
|
||||
// A handler that returns an exception leaves registers partially filled, so check the exception
|
||||
// first and forward it before validating the register count on the success path.
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -613,11 +535,17 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
switch (static_cast<FunctionCode>(function_code)) {
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS: {
|
||||
uint16_t start_address;
|
||||
uint16_t number_of_registers;
|
||||
status = this->parse_read_request_(data, MAX_NUM_OF_REGISTERS_TO_READ, LOG_STR("registers"), start_address,
|
||||
number_of_registers);
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
// PDU data: start address(2) + quantity(2).
|
||||
uint16_t start_address = helpers::get_data<uint16_t>(data.data(), 0);
|
||||
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
|
||||
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
|
||||
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
return;
|
||||
}
|
||||
status = this->check_register_range_(start_address, number_of_registers);
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
RegisterValues registers;
|
||||
@@ -643,7 +571,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
} else {
|
||||
status = this->parse_write_multiple_(data, start_address, registers);
|
||||
}
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
status = device->on_write_registers(start_address, registers);
|
||||
@@ -651,64 +580,6 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
response_len = 4;
|
||||
break;
|
||||
}
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS: {
|
||||
uint16_t start_address;
|
||||
uint16_t number_of_bits;
|
||||
status =
|
||||
this->parse_read_request_(data, MAX_NUM_OF_COILS_TO_READ, LOG_STR("bits"), start_address, number_of_bits);
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
return;
|
||||
}
|
||||
// Response: byte count(1) + packed bytes, written straight into the pre-zeroed response buffer. It
|
||||
// always fits: the parse above caps the count, and a static_assert bounds that against MAX_RAW_SIZE.
|
||||
const uint8_t byte_count = static_cast<uint8_t>(packed_bit_bytes(number_of_bits));
|
||||
response_buffer[response_len++] = byte_count;
|
||||
// Take the packed-bytes span off a span that knows response_buffer's real size, so a future non-zero
|
||||
// response_len (e.g. a prefix written before the packed data) is a bounds error, not a silent overrun.
|
||||
std::span<uint8_t> packed_out = std::span<uint8_t>(response_buffer).subspan(response_len, byte_count);
|
||||
std::fill(packed_out.begin(), packed_out.end(), 0);
|
||||
MutablePackedBits bits(packed_out, number_of_bits);
|
||||
if (static_cast<FunctionCode>(function_code) == FunctionCode::READ_COILS) {
|
||||
status = device->on_read_coils(start_address, bits);
|
||||
} else {
|
||||
status = device->on_read_discrete_inputs(start_address, bits);
|
||||
}
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
return;
|
||||
}
|
||||
response_len += byte_count;
|
||||
break;
|
||||
}
|
||||
case FunctionCode::WRITE_SINGLE_COIL: {
|
||||
// A single coil is handed to the device as a one-bit packed view, the same form a multiple-coil
|
||||
// write takes, so a device only ever implements one coil write handler.
|
||||
uint16_t start_address;
|
||||
bool value = false;
|
||||
status = this->parse_write_single_coil_(data, start_address, value);
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
return;
|
||||
}
|
||||
const uint8_t single_bit = value ? 0x01 : 0x00;
|
||||
status = device->on_write_coils(start_address, PackedBits(std::span<const uint8_t>(&single_bit, 1), 1));
|
||||
response_data = data.data(); // echo the request header per Modbus 6.5, 6.11
|
||||
response_len = 4;
|
||||
break;
|
||||
}
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS: {
|
||||
// Parse and validate the coil write PDU into a packed-bit view; reply with an exception on failure.
|
||||
uint16_t start_address;
|
||||
uint16_t count;
|
||||
std::span<const uint8_t> packed_bytes;
|
||||
status = this->parse_write_multiple_coils_(data, start_address, count, packed_bytes);
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
return;
|
||||
}
|
||||
status = device->on_write_coils(start_address, PackedBits(packed_bytes, count));
|
||||
response_data = data.data(); // echo the request header per Modbus 6.5, 6.11
|
||||
response_len = 4;
|
||||
break;
|
||||
}
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
|
||||
// PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) +
|
||||
// write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read.
|
||||
@@ -725,11 +596,12 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
|
||||
return;
|
||||
}
|
||||
status = this->check_address_range_(read_start_address, number_of_registers);
|
||||
status = this->check_register_range_(read_start_address, number_of_registers);
|
||||
if (!status.has_value()) {
|
||||
status = this->check_address_range_(write_start_address, number_of_write_registers);
|
||||
status = this->check_register_range_(write_start_address, number_of_write_registers);
|
||||
}
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
// Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read
|
||||
@@ -742,7 +614,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
// from the values it just stored.
|
||||
status = device->on_write_registers(write_start_address, write_registers);
|
||||
}
|
||||
if (this->rejected_(address, function_code, status)) {
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return;
|
||||
}
|
||||
RegisterValues registers;
|
||||
@@ -759,7 +632,9 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
|
||||
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION);
|
||||
return;
|
||||
}
|
||||
if (!this->rejected_(address, function_code, status)) {
|
||||
if (status.has_value()) {
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
} else {
|
||||
this->send_response_(address, function_code, response_data, response_len);
|
||||
}
|
||||
}
|
||||
@@ -858,19 +733,6 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con
|
||||
this->send_raw_(raw_frame, payload_len + 2);
|
||||
}
|
||||
|
||||
bool ModbusServerHub::rejected_(uint8_t address, uint8_t function_code, ResponseStatus status) {
|
||||
if (!status.has_value())
|
||||
return false;
|
||||
// The one place a rejection becomes an exception reply, so the log carries the transaction context a
|
||||
// device handler never has: which client-facing address and function code drew which exception. DEBUG
|
||||
// rather than WARN because an exception reply is a normal protocol outcome and arrives per frame - a
|
||||
// probing or broken client would otherwise flood the log. The parse helpers still WARN with specifics.
|
||||
ESP_LOGD(TAG, "Exception %" PRIu8 " replied to function 0x%02X for address %" PRIu8,
|
||||
static_cast<uint8_t>(status.value()), function_code, address);
|
||||
this->send_exception_(address, function_code, status.value());
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) {
|
||||
uint8_t raw_frame[3];
|
||||
raw_frame[0] = address;
|
||||
@@ -1021,8 +883,8 @@ void ModbusClientHub::sweep_() {
|
||||
}
|
||||
|
||||
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
|
||||
bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device,
|
||||
CommandOptions options) {
|
||||
bool ModbusClientHub::send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device,
|
||||
CommandOptions options) {
|
||||
// Requests refused here never enter the machine and get no callback - the false return is it.
|
||||
if (pdu.empty()) {
|
||||
ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address);
|
||||
@@ -1056,8 +918,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
continue;
|
||||
if (device == nullptr) {
|
||||
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
|
||||
const bool requeueable =
|
||||
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
|
||||
const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]);
|
||||
if (requeueable) {
|
||||
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
|
||||
} else {
|
||||
@@ -1134,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClient
|
||||
ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused");
|
||||
return;
|
||||
}
|
||||
this->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
|
||||
this->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
|
||||
}
|
||||
|
||||
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
|
||||
@@ -1216,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
|
||||
// - On failure (status engaged) the response is empty by design (see on_error()), so only the request
|
||||
// is validated.
|
||||
bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size());
|
||||
if (!custom && succeeded(status)) {
|
||||
if (!custom && !status.has_value()) {
|
||||
custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size());
|
||||
if (!custom && helpers::is_function_code_read(static_cast<uint8_t>(function_code))) {
|
||||
const bool bits =
|
||||
@@ -1237,34 +1098,22 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
|
||||
|
||||
switch (function_code) {
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
// FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a
|
||||
// plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its
|
||||
// response carries only that read data, and the write half is confirmed by the response arriving at all.
|
||||
// An exception routes here as well (the gate only validates the request when status is set), delivering
|
||||
// empty registers with the error in status - so a 0x17 subclass handles success and failure in the one
|
||||
// on_read_holding_registers() callback and never needs to also override on_error().
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
|
||||
case FunctionCode::READ_INPUT_REGISTERS: {
|
||||
// Decode the big-endian register words into host byte order. The gate guarantees a success response
|
||||
// carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the
|
||||
// capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On
|
||||
// failure the registers span is empty.
|
||||
RegisterValues registers;
|
||||
if (succeeded(status)) {
|
||||
if (!status.has_value()) {
|
||||
for (size_t i = 0; i != count_or_value; i++) {
|
||||
registers.push_back(helpers::get_data<uint16_t>(response_pdu.data(), 2 + 2 * i));
|
||||
}
|
||||
}
|
||||
std::span<const uint16_t> register_span(registers.data(), registers.size());
|
||||
if (function_code == FunctionCode::READ_INPUT_REGISTERS) {
|
||||
this->on_read_input_registers(start_address, register_span, status);
|
||||
} else if (function_code == FunctionCode::READ_HOLDING_REGISTERS ||
|
||||
function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
|
||||
if (function_code == FunctionCode::READ_HOLDING_REGISTERS) {
|
||||
this->on_read_holding_registers(start_address, register_span, status);
|
||||
} else {
|
||||
// Unreachable for the current case labels; match explicitly so a function code added to this group
|
||||
// later is diverted to on_custom_response() rather than silently delivered as a holding read.
|
||||
this->on_custom_response(request_pdu, response_pdu, status);
|
||||
this->on_read_input_registers(start_address, register_span, status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1275,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
|
||||
// PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them.
|
||||
std::span<const uint8_t> packed_bytes;
|
||||
uint16_t count = 0;
|
||||
if (succeeded(status)) {
|
||||
if (!status.has_value()) {
|
||||
packed_bytes = response_pdu.subspan(2);
|
||||
count = count_or_value;
|
||||
}
|
||||
@@ -1292,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
|
||||
// copy. On an exception the response has no value and the request copy is the only one.
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
case FunctionCode::WRITE_SINGLE_COIL: {
|
||||
const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE)
|
||||
const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE)
|
||||
? helpers::get_data<uint16_t>(response_pdu.data(), 3)
|
||||
: count_or_value;
|
||||
if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) {
|
||||
|
||||
@@ -151,7 +151,9 @@ struct ModbusDeviceCommand {
|
||||
static CommandPriority classify(uint8_t function_code) {
|
||||
if (helpers::is_function_code_exception(function_code))
|
||||
return CommandPriority::READ;
|
||||
if (helpers::is_function_code_write(function_code)) {
|
||||
const auto code = static_cast<FunctionCode>(function_code);
|
||||
if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER ||
|
||||
code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
|
||||
return CommandPriority::WRITE;
|
||||
}
|
||||
return CommandPriority::READ;
|
||||
@@ -160,7 +162,7 @@ struct ModbusDeviceCommand {
|
||||
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
|
||||
uint8_t max_pending() const {
|
||||
const uint8_t fc = this->frame.pdu()[0];
|
||||
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
|
||||
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc);
|
||||
return (requeueable && !this->continuous) ? 2 : 1;
|
||||
}
|
||||
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
|
||||
@@ -260,31 +262,19 @@ class ModbusClientHub : public Modbus {
|
||||
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
|
||||
bool tx_buffer_empty();
|
||||
bool tx_blocked() override;
|
||||
ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
|
||||
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
|
||||
this->queue_pdu(address,
|
||||
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
|
||||
payload_len),
|
||||
device);
|
||||
this->send_pdu(address,
|
||||
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
|
||||
payload_len),
|
||||
device);
|
||||
};
|
||||
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and
|
||||
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in
|
||||
/// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means
|
||||
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
|
||||
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
|
||||
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
|
||||
CommandOptions options = {});
|
||||
// Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions:
|
||||
// the bool return and the options argument arrived after that release, so nothing external can be
|
||||
// relying on them under this name. Callers who want the queued/refused answer move to queue_pdu().
|
||||
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
|
||||
"reports whether the request was accepted. Removed in 2027.2.0",
|
||||
"2026.8.0")
|
||||
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
|
||||
this->queue_pdu(address, pdu, device);
|
||||
}
|
||||
ESPDEPRECATED("Use queue_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
|
||||
// Queue a request; true once it is a live entry (resolving in one terminal), false if it never
|
||||
// entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback.
|
||||
bool send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
|
||||
CommandOptions options = {});
|
||||
ESPDEPRECATED("Use send_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
|
||||
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
|
||||
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the
|
||||
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently.
|
||||
@@ -325,12 +315,6 @@ class ModbusClientHub : public Modbus {
|
||||
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
|
||||
using ResponseStatus = std::optional<ExceptionCode>;
|
||||
|
||||
/// True when a transaction carried no exception. The optional holds the exception, so has_value() means
|
||||
/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the
|
||||
/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code
|
||||
/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer.
|
||||
inline bool succeeded(ResponseStatus status) { return !status.has_value(); }
|
||||
|
||||
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
|
||||
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
|
||||
// the capacity of this type.
|
||||
@@ -359,27 +343,9 @@ class ModbusServerHub : public Modbus {
|
||||
// Appends the big-endian register values in values to registers, in host byte order.
|
||||
void assemble_registers_(std::span<const uint8_t> values, RegisterValues ®isters);
|
||||
ModbusServerDevice *find_device_(uint8_t address);
|
||||
// Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space,
|
||||
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast
|
||||
// write is never answered, so the check cannot send it itself. Shared by the register and
|
||||
// coil/discrete-input handlers, which all address the same 16-bit space.
|
||||
ResponseStatus check_address_range_(uint16_t start_address, uint16_t count);
|
||||
|
||||
// Parses a read request PDU (start address(2) + quantity(2)), shared by the register and
|
||||
// coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the
|
||||
// function code; entity_name only labels the rejection log.
|
||||
ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name,
|
||||
uint16_t &start_address, uint16_t &count);
|
||||
|
||||
// Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed
|
||||
// bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take.
|
||||
ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value);
|
||||
|
||||
// Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive
|
||||
// buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and
|
||||
// broadcast paths so the two validate identically.
|
||||
ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count,
|
||||
std::span<const uint8_t> &packed_bytes);
|
||||
// Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space,
|
||||
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required.
|
||||
ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers);
|
||||
|
||||
// Builds the body of a register read response (byte count followed by the big-endian register values) into
|
||||
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
|
||||
@@ -390,9 +356,6 @@ class ModbusServerHub : public Modbus {
|
||||
uint16_t number_of_registers, const RegisterValues ®isters,
|
||||
std::span<uint8_t> response_buffer, uint16_t &response_len);
|
||||
void send_raw_(const uint8_t *payload, uint16_t len);
|
||||
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
|
||||
// Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart.
|
||||
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
|
||||
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
|
||||
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
|
||||
uint8_t expecting_peer_response_{0};
|
||||
@@ -410,7 +373,7 @@ class ModbusServerHub : public Modbus {
|
||||
|
||||
/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data),
|
||||
/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by
|
||||
/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return)
|
||||
/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return)
|
||||
/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request.
|
||||
/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all
|
||||
/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from
|
||||
@@ -420,7 +383,7 @@ class ModbusServerHub : public Modbus {
|
||||
/// merges into it).
|
||||
///
|
||||
/// Invariants:
|
||||
/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
|
||||
/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
|
||||
/// entry through its callback-free transition methods.
|
||||
/// - Public entry points can never trigger a callback synchronously.
|
||||
/// - Callbacks are delivered only from within loop().
|
||||
@@ -522,85 +485,66 @@ class ModbusClientDevice {
|
||||
/// to handle custom traffic (which also silences the warning).
|
||||
virtual void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
|
||||
ResponseStatus status);
|
||||
ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0")
|
||||
ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0")
|
||||
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
|
||||
const uint8_t *payload = nullptr) {
|
||||
this->parent_->queue_pdu(
|
||||
this->parent_->send_pdu(
|
||||
this->address_,
|
||||
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
|
||||
this);
|
||||
}
|
||||
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will
|
||||
/// follow, false = refused at the door and nothing further happens. Neither means the frame is on
|
||||
/// the wire; on_sent() reports that.
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
|
||||
return this->parent_->queue_pdu(this->address_, pdu, this, options);
|
||||
/// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow),
|
||||
/// false = refused at the door (no callback).
|
||||
bool send_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
|
||||
return this->parent_->send_pdu(this->address_, pdu, this, options);
|
||||
}
|
||||
// Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options.
|
||||
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
|
||||
"reports whether the request was accepted. Removed in 2027.2.0",
|
||||
"2026.8.0")
|
||||
void send_pdu(std::span<const uint8_t> pdu) { this->queue_pdu(pdu); }
|
||||
ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
|
||||
void send_raw(const std::vector<uint8_t> &payload) {
|
||||
ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
|
||||
bool send_raw(const std::vector<uint8_t> &payload) {
|
||||
if (payload.empty())
|
||||
return; // too short to contain a PDU; refused at the door like any invalid send
|
||||
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
|
||||
return false; // too short to contain a PDU; refused at the door like any invalid send
|
||||
return this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
|
||||
}
|
||||
// The typed request builders below all queue through queue_pdu(), so they share its contract: true
|
||||
// means the request is queued and will resolve in exactly one terminal callback, false means it was
|
||||
// refused outright with no callback. Neither says the frame has been transmitted - on_sent() does.
|
||||
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
|
||||
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
|
||||
// create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return.
|
||||
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
|
||||
CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
|
||||
number_of_entities),
|
||||
options);
|
||||
return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
|
||||
number_of_entities),
|
||||
options);
|
||||
}
|
||||
bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
|
||||
return this->queue_pdu(
|
||||
return this->send_pdu(
|
||||
helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options);
|
||||
}
|
||||
bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
|
||||
return this->queue_pdu(
|
||||
return this->send_pdu(
|
||||
helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options);
|
||||
}
|
||||
bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) {
|
||||
return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options);
|
||||
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options);
|
||||
}
|
||||
bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) {
|
||||
return this->queue_pdu(
|
||||
helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options);
|
||||
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs),
|
||||
options);
|
||||
}
|
||||
bool write_single_register(uint16_t start_address, uint16_t value) {
|
||||
return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value));
|
||||
return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value));
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value));
|
||||
return this->send_pdu(helpers::create_write_single_coil_pdu(address, value));
|
||||
}
|
||||
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values));
|
||||
return this->send_pdu(helpers::create_write_registers_pdu(start_address, values));
|
||||
}
|
||||
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
|
||||
/// overload.
|
||||
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values));
|
||||
return this->send_pdu(helpers::create_write_coils_pdu(start_address, values));
|
||||
}
|
||||
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
|
||||
/// read-modify-write needs no unpack/repack.
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
}
|
||||
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the
|
||||
/// read registers, the same wire shape as a holding-register read). A device exception - typically a
|
||||
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status,
|
||||
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
|
||||
/// needs to also override on_error().
|
||||
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
|
||||
std::span<const uint16_t> write_values) {
|
||||
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
|
||||
write_start_address, write_values));
|
||||
return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
}
|
||||
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
|
||||
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
|
||||
@@ -673,26 +617,18 @@ class ModbusServerDevice {
|
||||
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) {
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
/// Coil/discrete-input reads: set the requested bits (bit 0 = the coil at start_address) with
|
||||
/// bits.set(). The view covers bits.size() pre-zeroed bits and writes land directly in the hub's
|
||||
/// response buffer (no copy); it is only valid during the call.
|
||||
virtual ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) {
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
virtual ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) {
|
||||
return this->on_read_bits(start_address, bits);
|
||||
};
|
||||
virtual ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) {
|
||||
return this->on_read_bits(start_address, bits);
|
||||
};
|
||||
/// Coil writes deliver the values as a PackedBits view over the hub's receive buffer (only valid
|
||||
/// during the call). A single-coil write (FC 0x05) arrives as bits.size() == 1.
|
||||
virtual ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) {
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
// Hub entry point for broadcast (address 0) writes, which are never answered.
|
||||
ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) {
|
||||
this->broadcast_write_ = true;
|
||||
ResponseStatus status = this->on_write_registers(start_address, registers);
|
||||
this->broadcast_write_ = false;
|
||||
return status;
|
||||
}
|
||||
|
||||
protected:
|
||||
uint8_t address_{0};
|
||||
// Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence.
|
||||
bool broadcast_write_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::modbus
|
||||
@@ -128,19 +128,6 @@ static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload m
|
||||
/// Bits pack 8 per data byte, rounded up to whole bytes.
|
||||
constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; }
|
||||
|
||||
// A coil/discrete-input read answers with byte count(1) + packed_bit_bytes(count) bytes, which has to fit
|
||||
// the raw frame body. The runtime check on that path catches a caller entering with bytes already written;
|
||||
// this catches the other way in, raising the ceiling past what a frame can carry.
|
||||
static_assert(1 + packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ) <= MAX_RAW_SIZE,
|
||||
"MAX_NUM_OF_COILS_TO_READ yields a read response larger than MAX_RAW_SIZE");
|
||||
static_assert(1 + packed_bit_bytes(MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) <= MAX_RAW_SIZE,
|
||||
"MAX_NUM_OF_DISCRETE_INPUTS_TO_READ yields a read response larger than MAX_RAW_SIZE");
|
||||
|
||||
// The coil and discrete-input ceilings are separate limits in the spec but hold the same value, so the
|
||||
// read paths validate both against MAX_NUM_OF_COILS_TO_READ. Should the spec ever split them, this fires.
|
||||
static_assert(MAX_NUM_OF_COILS_TO_READ == MAX_NUM_OF_DISCRETE_INPUTS_TO_READ,
|
||||
"the coil and discrete-input read ceilings must match");
|
||||
|
||||
/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout
|
||||
* coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the
|
||||
* two cannot desynchronize. The view does not own the bytes - it is only valid while they are.
|
||||
|
||||
@@ -8,11 +8,10 @@ namespace esphome::modbus::helpers {
|
||||
static const char *const TAG = "modbus_helpers";
|
||||
|
||||
// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum,
|
||||
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space.
|
||||
// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a
|
||||
// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed.
|
||||
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space
|
||||
// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass).
|
||||
static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) {
|
||||
return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity);
|
||||
return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u;
|
||||
}
|
||||
|
||||
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and
|
||||
@@ -70,10 +69,8 @@ uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
return 5; // function(1) + output/register address(2) + value(2)
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
// function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte).
|
||||
return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0);
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
// function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register).
|
||||
// function(1) + start address(2) + quantity(2) + byte count(1) + data
|
||||
return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
|
||||
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
@@ -308,20 +305,16 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
|
||||
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
// Append a 16-bit value to a PDU in big-endian (wire) byte order.
|
||||
template<size_t CAP> static void append_pdu_word(StaticVector<uint8_t, CAP> &pdu, uint16_t value) {
|
||||
pdu.push_back(value >> 8);
|
||||
pdu.push_back(value >> 0);
|
||||
}
|
||||
|
||||
// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit
|
||||
// fields (start address + quantity for reads and multi-writes, address + value for single writes).
|
||||
template<size_t CAP>
|
||||
static void append_pdu_header(StaticVector<uint8_t, CAP> &pdu, FunctionCode function_code, uint16_t first,
|
||||
uint16_t second) {
|
||||
pdu.push_back(static_cast<uint8_t>(function_code));
|
||||
append_pdu_word(pdu, first);
|
||||
append_pdu_word(pdu, second);
|
||||
pdu.push_back(first >> 8);
|
||||
pdu.push_back(first >> 0);
|
||||
pdu.push_back(second >> 8);
|
||||
pdu.push_back(second >> 0);
|
||||
}
|
||||
|
||||
// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one
|
||||
@@ -340,7 +333,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
|
||||
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
if (!address_range_fits(start_address, number_of_entities)) {
|
||||
if (uint32_t(start_address) + number_of_entities > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
|
||||
start_address);
|
||||
return pdu;
|
||||
@@ -383,7 +376,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
|
||||
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
|
||||
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
|
||||
if (is_function_code_read_only(static_cast<uint8_t>(function_code))) {
|
||||
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
|
||||
if (values != nullptr || values_len > 0) {
|
||||
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
|
||||
static_cast<uint8_t>(function_code));
|
||||
@@ -422,7 +415,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
|
||||
static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
if (!is_single && !address_range_fits(start_address, number_of_entities)) {
|
||||
if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
|
||||
start_address);
|
||||
return pdu;
|
||||
@@ -465,59 +458,29 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
|
||||
return pdu;
|
||||
}
|
||||
|
||||
// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not
|
||||
// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure
|
||||
// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in
|
||||
// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each
|
||||
// failure names its reason, and taking size_t so an oversize span is caught before any narrowing.
|
||||
static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity,
|
||||
uint16_t max_quantity) {
|
||||
if (quantity == 0 || quantity > max_quantity) {
|
||||
ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity);
|
||||
return false;
|
||||
}
|
||||
if (!address_range_fits(start_address, quantity)) {
|
||||
ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role),
|
||||
quantity, start_address);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) {
|
||||
if (values.empty()) {
|
||||
ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request");
|
||||
return pdu;
|
||||
}
|
||||
// Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE.
|
||||
if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) {
|
||||
ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(),
|
||||
MAX_NUM_OF_REGISTERS_TO_WRITE);
|
||||
return pdu;
|
||||
}
|
||||
if (uint32_t(start_address) + values.size() > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(),
|
||||
start_address);
|
||||
return pdu;
|
||||
}
|
||||
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
|
||||
pdu.push_back(static_cast<uint8_t>(values.size() * 2)); // byte count
|
||||
for (auto v : values) {
|
||||
append_pdu_word(pdu, v);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
|
||||
uint16_t write_start_address,
|
||||
std::span<const uint16_t> write_values) {
|
||||
PduBuffer pdu;
|
||||
if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) {
|
||||
return pdu;
|
||||
}
|
||||
if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(),
|
||||
MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) {
|
||||
return pdu;
|
||||
}
|
||||
// fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values.
|
||||
const auto write_count = static_cast<uint16_t>(write_values.size());
|
||||
pdu.push_back(static_cast<uint8_t>(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS));
|
||||
append_pdu_word(pdu, read_start_address);
|
||||
append_pdu_word(pdu, read_count);
|
||||
append_pdu_word(pdu, write_start_address);
|
||||
append_pdu_word(pdu, write_count);
|
||||
pdu.push_back(static_cast<uint8_t>(write_count * 2)); // byte count
|
||||
for (auto v : write_values) {
|
||||
append_pdu_word(pdu, v);
|
||||
auto decoded_value = decode_value(v);
|
||||
pdu.push_back(decoded_value[0]);
|
||||
pdu.push_back(decoded_value[1]);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
@@ -547,7 +510,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed
|
||||
ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE);
|
||||
return;
|
||||
}
|
||||
if (!address_range_fits(start_address, count)) {
|
||||
if (uint32_t(start_address) + count > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address);
|
||||
return;
|
||||
}
|
||||
@@ -583,7 +546,12 @@ static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const
|
||||
return pdu;
|
||||
}
|
||||
CoilPackBuffer packed;
|
||||
pack_bits(packed, values);
|
||||
for (size_t i = 0; i != count; i++) {
|
||||
if (i % 8 == 0)
|
||||
packed.push_back(0);
|
||||
if (values[i])
|
||||
packed[i / 8] |= (1 << (i % 8));
|
||||
}
|
||||
build_write_coils_pdu(pdu, start_address, PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), count));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user