mirror of
https://github.com/esphome/esphome.git
synced 2026-10-26 07:52:14 +00:00
Compare commits
304
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce475ccef | ||
|
|
2016884210 | ||
|
|
d3f4f44c7b | ||
|
|
6df15be714 | ||
|
|
a03fe3c38e | ||
|
|
afc8c51f93 | ||
|
|
ef7ed69f9d | ||
|
|
ec32802662 | ||
|
|
433d8046b1 | ||
|
|
8ecb981656 | ||
|
|
8c990573d7 | ||
|
|
bdec0c1485 | ||
|
|
70e85d939d | ||
|
|
25b3c2820a | ||
|
|
ef53e675ff | ||
|
|
10b569491d | ||
|
|
71b1b58e8d | ||
|
|
4ab67d867f | ||
|
|
a2a46944ab | ||
|
|
537d1643cb | ||
|
|
07744bb53e | ||
|
|
c5d69ce22b | ||
|
|
21fb25a45f | ||
|
|
eb3394e332 | ||
|
|
88eedeec3f | ||
|
|
c9fe8b6b45 | ||
|
|
b7e652399b | ||
|
|
955998021c | ||
|
|
5a6983822d | ||
|
|
11a3372843 | ||
|
|
e388c459b3 | ||
|
|
c83ea861eb | ||
|
|
955afc4847 | ||
|
|
7a01c71022 | ||
|
|
9f254c69a1 | ||
|
|
1b6cc0bf18 | ||
|
|
0801211ba1 | ||
|
|
5a2f059be7 | ||
|
|
14998c0905 | ||
|
|
d69a1e9bf9 | ||
|
|
8eb5d17f3d | ||
|
|
808afc024d | ||
|
|
0bb98b715f | ||
|
|
90af765fae | ||
|
|
f17b18d51a | ||
|
|
5f11c430cc | ||
|
|
baf57195e2 | ||
|
|
27a97f36af | ||
|
|
0a28a5b023 | ||
|
|
5c6e62ffe0 | ||
|
|
0801b29617 | ||
|
|
b00ad7ca8a | ||
|
|
7ad51b4b8f | ||
|
|
65c1b06f7f | ||
|
|
e158205e4c | ||
|
|
f5debdad32 | ||
|
|
2a95fa44dc | ||
|
|
314557f83b | ||
|
|
e4e464a3b9 | ||
|
|
a41dbed69c | ||
|
|
ba10832ed7 | ||
|
|
d6b35f8ccf | ||
|
|
03efcf6bb5 | ||
|
|
0d46b1047e | ||
|
|
456f5b3042 | ||
|
|
83c9603b4b | ||
|
|
ad9a41ed61 | ||
|
|
e6b7b3a028 | ||
|
|
9a4a550fb9 | ||
|
|
42dc4025c9 | ||
|
|
901fd35a7e | ||
|
|
4344ffd682 | ||
|
|
1c671be1bd | ||
|
|
52dc51c7c6 | ||
|
|
671b6216a7 | ||
|
|
5764ca607d | ||
|
|
273c80ecd3 | ||
|
|
1933126ad7 | ||
|
|
64034b5b23 | ||
|
|
830854d661 | ||
|
|
87b7076cd1 | ||
|
|
5ec7047151 | ||
|
|
45d921f77a | ||
|
|
321b00143d | ||
|
|
e6e9016ed4 | ||
|
|
a292aa2654 | ||
|
|
4353f5ac9c | ||
|
|
4925f6648b | ||
|
|
47bd8f5ffc | ||
|
|
31771da92c | ||
|
|
1978f203bc | ||
|
|
ae906ada94 | ||
|
|
6a2465da63 | ||
|
|
9a5aab63d5 | ||
|
|
deeec17d0d | ||
|
|
735f810f08 | ||
|
|
cd48023a56 | ||
|
|
9330204908 | ||
|
|
45501056d8 | ||
|
|
2e6ad94bd1 | ||
|
|
5994105d34 | ||
|
|
0ac9a0f8bd | ||
|
|
77b8b384bd | ||
|
|
3728669fa6 | ||
|
|
578e4774a9 | ||
|
|
8696255ccc | ||
|
|
594931265b | ||
|
|
b0f753a7a2 | ||
|
|
fc69d8543e | ||
|
|
d586a6c8d4 | ||
|
|
9301391792 | ||
|
|
1b801427c4 | ||
|
|
be391feaa9 | ||
|
|
7e369f332c | ||
|
|
a40ecfdf9c | ||
|
|
9a6765eb8f | ||
|
|
1d0a87daa8 | ||
|
|
c98dd74e11 | ||
|
|
aceff27948 | ||
|
|
211337cd6d | ||
|
|
4276815734 | ||
|
|
3c57064bf2 | ||
|
|
f9be82d1a6 | ||
|
|
10a0bf0ff8 | ||
|
|
484674b85d | ||
|
|
49c1547fda | ||
|
|
7b4353b417 | ||
|
|
5f20346d54 | ||
|
|
356c1d9134 | ||
|
|
9534a1df55 | ||
|
|
fb64b18047 | ||
|
|
5572bf77dd | ||
|
|
d8ec76e02b | ||
|
|
cf5faa454c | ||
|
|
55d14c6fbf | ||
|
|
d86bdb36cf | ||
|
|
c64684dd00 | ||
|
|
ed002feee9 | ||
|
|
38f31d85b6 | ||
|
|
d04ef2378e | ||
|
|
5e26633231 | ||
|
|
6c78b480c4 | ||
|
|
3d33a16b71 | ||
|
|
e3aa8773e6 | ||
|
|
be5e00aefd | ||
|
|
9c5ebdffc9 | ||
|
|
9554e1a212 | ||
|
|
28491f83b4 | ||
|
|
2f71ec16d7 | ||
|
|
3daa1d2450 | ||
|
|
a513cef4e2 | ||
|
|
fa17215053 | ||
|
|
073d9a72a0 | ||
|
|
3a24d0b03b | ||
|
|
b229bd4d8a | ||
|
|
c8e240198d | ||
|
|
0512339c58 | ||
|
|
e95bebb7d0 | ||
|
|
084ff9d6e3 | ||
|
|
4a9e5f5da9 | ||
|
|
f6158597ac | ||
|
|
8924640593 | ||
|
|
26c000c417 | ||
|
|
7cb32f9546 | ||
|
|
13de0275c8 | ||
|
|
22af86a65b | ||
|
|
83b099d708 | ||
|
|
6957e2ef15 | ||
|
|
743a10fa3e | ||
|
|
f7b1e9ae65 | ||
|
|
53a5c7b051 | ||
|
|
54c50c07c5 | ||
|
|
64df0c1da1 | ||
|
|
9c31e08e95 | ||
|
|
93124f1f1f | ||
|
|
8abb5f102d | ||
|
|
12fb1b0697 | ||
|
|
81216da8e7 | ||
|
|
ed2cdad9b4 | ||
|
|
be818a5bf2 | ||
|
|
444a217ee7 | ||
|
|
93f9d4a1ad | ||
|
|
ac7d39a8c1 | ||
|
|
612033062f | ||
|
|
f9dbd24a2a | ||
|
|
833a835e0f | ||
|
|
399dc26236 | ||
|
|
0d9aea9594 | ||
|
|
f2261d6632 | ||
|
|
d224a82e38 | ||
|
|
ce436ff8b4 | ||
|
|
cbda1e71be | ||
|
|
ceabfeb6f4 | ||
|
|
5e11a886f1 | ||
|
|
e771b2522a | ||
|
|
ee96293c4c | ||
|
|
fa18f5a0ad | ||
|
|
269d52ac2e | ||
|
|
06a52f682f | ||
|
|
409ff027cc | ||
|
|
3c7e5000a5 | ||
|
|
7af211ab54 | ||
|
|
cc81069d02 | ||
|
|
ba567cecc3 | ||
|
|
ee7fb83334 | ||
|
|
9db31db24d | ||
|
|
ceca66c01a | ||
|
|
dc97c5a161 | ||
|
|
97bbcf55d9 | ||
|
|
d135ac97e5 | ||
|
|
296dd19361 | ||
|
|
138304cfbe | ||
|
|
9fef1862be | ||
|
|
80758f11aa | ||
|
|
9d8362738f | ||
|
|
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
@@ -16,6 +16,8 @@ esphome/components/a01nyub/* @MrSuicideParrot
|
||||
esphome/components/a02yyuw/* @TH-Braemer
|
||||
esphome/components/absolute_humidity/* @DAVe3283
|
||||
esphome/components/ac_dimmer/* @glmnet
|
||||
esphome/components/adafruit_ble/* @omersiar
|
||||
esphome/components/adafruit_ble/ota/* @omersiar
|
||||
esphome/components/adc/* @esphome/core
|
||||
esphome/components/adc128s102/* @DeerMaximum
|
||||
esphome/components/addressable_light/* @justfalter
|
||||
@@ -86,6 +88,7 @@ esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_midi/* @bogdanr
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
|
||||
@@ -348,8 +348,9 @@ class ApplyField:
|
||||
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
|
||||
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
|
||||
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
|
||||
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
|
||||
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
|
||||
is not the right spelling, or the value depends on another entity's config, such as the unit
|
||||
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
|
||||
``type_``. An absent key emits nothing.
|
||||
"""
|
||||
|
||||
conf_key: str | tuple[str, ...]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DOMAIN = "adafruit_ble"
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Host-side Adafruit nRF52 bootloader BLE OTA (Nordic legacy DFU).
|
||||
|
||||
The Adafruit bootloader only serves its DFU service while it is in DFU mode, so
|
||||
an update has two steps: ask the running application to reboot into the
|
||||
bootloader (the trigger service built into the ``adafruit_ble`` OTA component),
|
||||
then push the DFU package to the bootloader itself.
|
||||
|
||||
Reference implementation of the protocol:
|
||||
https://github.com/recrof/nrf_dfu_py/blob/main/dfu_lib.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from bleak.exc import (
|
||||
BleakDBusError,
|
||||
BleakDeviceNotFoundError,
|
||||
BleakError,
|
||||
BleakGATTProtocolError,
|
||||
BleakGATTProtocolErrorCode,
|
||||
)
|
||||
|
||||
from esphome.components.nrf52.ble_logger import MAC_ADDRESS_PATTERN
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.espota2 import ProgressBar
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Nordic legacy DFU service, used by the Adafruit nRF52 bootloader.
|
||||
DFU_SERVICE_UUID = "00001530-1212-efde-1523-785feabcd123"
|
||||
DFU_CONTROL_POINT_UUID = "00001531-1212-efde-1523-785feabcd123"
|
||||
DFU_PACKET_UUID = "00001532-1212-efde-1523-785feabcd123"
|
||||
|
||||
# ESPHome DFU trigger service, exposed by the application (ota_adafruit_ble.cpp).
|
||||
# Kept distinct from the bootloader's DFU service so the two can be told apart.
|
||||
TRIGGER_SERVICE_UUID = "e5b10001-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_CHARACTERISTIC_UUID = "e5b10002-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_MAGIC = b"DFU"
|
||||
|
||||
_OP_START_DFU = 0x01
|
||||
_OP_INIT_DFU_PARAMS = 0x02
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE = 0x03
|
||||
_OP_VALIDATE = 0x04
|
||||
_OP_ACTIVATE_AND_RESET = 0x05
|
||||
_OP_RESET = 0x06
|
||||
_OP_PACKET_RECEIPT_NOTIF_REQ = 0x08
|
||||
_OP_RESPONSE_CODE = 0x10
|
||||
_OP_PACKET_RECEIPT_NOTIF = 0x11
|
||||
|
||||
_UPLOAD_MODE_APPLICATION = 0x04
|
||||
_RESPONSE_SUCCESS = 0x01
|
||||
_RESPONSE_INVALID_STATE = 0x02
|
||||
_RESPONSE_STATUS_NAMES = {
|
||||
0x02: "invalid state",
|
||||
0x03: "not supported",
|
||||
0x04: "data size exceeds limit",
|
||||
0x05: "CRC error",
|
||||
0x06: "operation failed",
|
||||
}
|
||||
|
||||
# The bootloader runs out of memory when more than 8 packets are unacknowledged.
|
||||
_PRN = 8
|
||||
# The bootloader needs a moment between START_DFU and the image size packet;
|
||||
# sending the size too early leaves the start command unanswered.
|
||||
_START_DELAY = 0.6
|
||||
# A dropped link mid-update is usually transient, so the sequence is retried.
|
||||
_ATTEMPTS = 3
|
||||
_RETRY_DELAY = 3.0
|
||||
# ATT payload cap; the negotiated MTU is normally smaller.
|
||||
_MAX_CHUNK_SIZE = 244
|
||||
# The bootloader rejects image sizes and data packets that are not whole words.
|
||||
_WORD_SIZE = 4
|
||||
_SCAN_TIMEOUT = 10.0
|
||||
_RESPONSE_TIMEOUT = 30.0
|
||||
# Starting the update erases flash and finishing it writes the last pages.
|
||||
_LONG_RESPONSE_TIMEOUT = 60.0
|
||||
_RECEIPT_TIMEOUT = 2.0
|
||||
# Time the bootloader needs to start advertising after the DFU request.
|
||||
_REBOOT_TIMEOUT = 20.0
|
||||
# Without MAC addresses (macOS), the bootloader is the one DFU device that was
|
||||
# not there before the request: the scan that lists those before it, and how
|
||||
# long to keep watching for a second new one after the first appears.
|
||||
_DFU_SNAPSHOT_TIMEOUT = 3.0
|
||||
_NEW_DEVICE_SETTLE = 3.0
|
||||
# macOS reports a device once per scan session, so a fresh session is needed to
|
||||
# see whether the application still advertises.
|
||||
_APP_CHECK_TIMEOUT = 3.0
|
||||
# Error responses to the DFU mode request that pairing the device would fix.
|
||||
_PAIRING_ERRORS = {
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHENTICATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE,
|
||||
}
|
||||
|
||||
BOOTLOADER_HINT = (
|
||||
"A device left in its bootloader by a failed upload advertises at its MAC "
|
||||
"address + 1; pass that address with --device (on macOS, the UUID of the "
|
||||
"DFU device)."
|
||||
)
|
||||
|
||||
|
||||
class _DfuRejectedError(EsphomeError):
|
||||
"""The bootloader answered with a failure status; retrying will not help."""
|
||||
|
||||
|
||||
class _DfuStaleStateError(_DfuRejectedError):
|
||||
"""START found the bootloader mid-update from an interrupted transfer.
|
||||
|
||||
The failed START resets the bootloader, so one more attempt can succeed.
|
||||
"""
|
||||
|
||||
|
||||
def _rejected_error(request_op: int, step: str, status: int) -> _DfuRejectedError:
|
||||
name = _RESPONSE_STATUS_NAMES.get(status, "unknown")
|
||||
error = (
|
||||
_DfuStaleStateError
|
||||
if request_op == _OP_START_DFU and status == _RESPONSE_INVALID_STATE
|
||||
else _DfuRejectedError
|
||||
)
|
||||
return error(
|
||||
f"DFU {step} (opcode {request_op:#04x}) rejected by the bootloader "
|
||||
f"with status {status:#04x} ({name})"
|
||||
)
|
||||
|
||||
|
||||
class _DfuSession:
|
||||
"""Control point notifications and packet receipts of one DFU run."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._responses: asyncio.Queue[tuple[int, int]] = asyncio.Queue()
|
||||
self._receipt = asyncio.Event()
|
||||
self._rejection: tuple[int, int] | None = None
|
||||
self.receipts = 0
|
||||
|
||||
def notification_handler(self, _sender: int, data: bytearray) -> None:
|
||||
if not data:
|
||||
_LOGGER.debug("DFU: ignoring an empty notification")
|
||||
return
|
||||
opcode = data[0]
|
||||
if opcode == _OP_RESPONSE_CODE and len(data) >= 3:
|
||||
self._responses.put_nowait((data[1], data[2]))
|
||||
if data[2] != _RESPONSE_SUCCESS:
|
||||
# Wakes a receipt wait, which no receipt would end after a rejection
|
||||
self._rejection = (data[1], data[2])
|
||||
self._receipt.set()
|
||||
elif opcode == _OP_PACKET_RECEIPT_NOTIF:
|
||||
self.receipts += 1
|
||||
self._receipt.set()
|
||||
else:
|
||||
_LOGGER.debug("DFU: ignoring notification %s", data.hex())
|
||||
|
||||
async def expect_success(
|
||||
self, request_op: int, step: str, timeout: float = _RESPONSE_TIMEOUT
|
||||
) -> None:
|
||||
try:
|
||||
response_op, status = await asyncio.wait_for(self._responses.get(), timeout)
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for the response to opcode {request_op:#04x}"
|
||||
) from err
|
||||
if response_op != request_op:
|
||||
raise EsphomeError(
|
||||
f"DFU got a response for opcode {response_op:#04x}, "
|
||||
f"expected {request_op:#04x}"
|
||||
)
|
||||
if status != _RESPONSE_SUCCESS:
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
def raise_if_rejected(self, step: str) -> None:
|
||||
"""Raise if the bootloader already answered a command with a failure."""
|
||||
if self._rejection is not None:
|
||||
request_op, status = self._rejection
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
async def wait_for_receipts(self, count: int, step: str) -> None:
|
||||
"""Wait until ``count`` packet receipts have arrived in total."""
|
||||
try:
|
||||
async with asyncio.timeout(_RECEIPT_TIMEOUT):
|
||||
while self.receipts < count:
|
||||
self.raise_if_rejected(step)
|
||||
# Cleared only while the count is short, so no receipt is lost
|
||||
self._receipt.clear()
|
||||
await self._receipt.wait()
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for packet receipt {count}"
|
||||
) from err
|
||||
|
||||
|
||||
def _service_uuids(advertisement) -> set[str]:
|
||||
return {uuid.lower() for uuid in (advertisement.service_uuids or [])}
|
||||
|
||||
|
||||
def _next_address(address: str) -> str | None:
|
||||
"""The Adafruit bootloader can come up one address above the application."""
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
return None
|
||||
prefix, last = address[:-2], int(address[-2:], 16)
|
||||
return f"{prefix}{((last + 1) & 0xFF):02X}"
|
||||
|
||||
|
||||
async def _dfu_devices() -> set[str]:
|
||||
"""Addresses of the devices advertising the DFU service right now."""
|
||||
found = await BleakScanner.discover(timeout=_DFU_SNAPSHOT_TIMEOUT, return_adv=True)
|
||||
return {
|
||||
device.address
|
||||
for device, advertisement in found.values()
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DfuPackage:
|
||||
"""The application image and init packet of an Adafruit DFU package."""
|
||||
|
||||
name: str
|
||||
image: bytes
|
||||
init_packet: bytes
|
||||
|
||||
|
||||
def load_firmware(firmware: Path) -> DfuPackage:
|
||||
"""Read and check a DFU package, before the device is asked to reboot."""
|
||||
if not firmware.is_file():
|
||||
raise EsphomeError(
|
||||
f"DFU package not found: {firmware}. Compile the firmware first."
|
||||
)
|
||||
try:
|
||||
return _read_package(firmware)
|
||||
except (zipfile.BadZipFile, KeyError, ValueError) as err:
|
||||
raise EsphomeError(f"Invalid DFU package {firmware}: {err}") from err
|
||||
|
||||
|
||||
def _read_package(firmware: Path) -> DfuPackage:
|
||||
with zipfile.ZipFile(firmware) as archive:
|
||||
names = archive.namelist()
|
||||
if "manifest.json" in names:
|
||||
bin_file, dat_file = _manifest_files(
|
||||
firmware, json.loads(archive.read("manifest.json"))
|
||||
)
|
||||
else:
|
||||
bins = [name for name in names if name.endswith(".bin")]
|
||||
dats = [name for name in names if name.endswith(".dat")]
|
||||
if len(bins) > 1 or len(dats) > 1:
|
||||
raise ValueError("more than one .bin or .dat file and no manifest")
|
||||
if not bins or not dats:
|
||||
raise EsphomeError(
|
||||
f"Could not find the application image in {firmware}"
|
||||
)
|
||||
bin_file, dat_file = bins[0], dats[0]
|
||||
image = archive.read(bin_file)
|
||||
init_packet = archive.read(dat_file)
|
||||
if not image:
|
||||
raise EsphomeError(f"Empty application image in {firmware}")
|
||||
if len(image) % _WORD_SIZE:
|
||||
raise EsphomeError(
|
||||
f"The application image in {firmware} is {len(image)} bytes, "
|
||||
f"which the bootloader rejects as it is not a multiple of {_WORD_SIZE}"
|
||||
)
|
||||
return DfuPackage(firmware.name, image, init_packet)
|
||||
|
||||
|
||||
def _manifest_files(firmware: Path, manifest: object) -> tuple[str, str]:
|
||||
"""The image and init packet names of the application in ``manifest``."""
|
||||
if isinstance(manifest, dict):
|
||||
manifest = manifest.get("manifest", manifest)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("manifest.json is not a JSON object")
|
||||
application = manifest.get("application")
|
||||
if application is None:
|
||||
raise EsphomeError(f"{firmware} is not an application update package")
|
||||
if not isinstance(application, dict):
|
||||
raise ValueError("the manifest application entry is not a JSON object")
|
||||
bin_file = application.get("bin_file")
|
||||
dat_file = application.get("dat_file")
|
||||
if not isinstance(bin_file, str) or not isinstance(dat_file, str):
|
||||
raise ValueError("the manifest names no bin_file and dat_file")
|
||||
return bin_file, dat_file
|
||||
|
||||
|
||||
class _DeviceNotFoundError(EsphomeError):
|
||||
"""The device did not answer at the address it was asked for."""
|
||||
|
||||
|
||||
def _raise_if_not_paired(address: str, err: Exception) -> None:
|
||||
if isinstance(err, BleakDBusError) and "NotPermitted" in err.dbus_error:
|
||||
raise EsphomeError(
|
||||
f"Cannot connect to {address}: Make sure the device is paired."
|
||||
) from err
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(address: str) -> AsyncIterator[BleakClient]:
|
||||
client = BleakClient(address, timeout=_SCAN_TIMEOUT)
|
||||
try:
|
||||
await client.connect()
|
||||
except BleakDeviceNotFoundError as err:
|
||||
raise _DeviceNotFoundError(f"Device {address} was not found") from err
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
raise EsphomeError(f"Could not connect to {address}: {err}") from err
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
async def enter_dfu_mode(address: str) -> str:
|
||||
"""Return the address of the bootloader in DFU mode.
|
||||
|
||||
A running application is first asked to reboot into the bootloader. A
|
||||
device that is not found may still be in its bootloader from a failed
|
||||
upload, which can advertise one address above the application.
|
||||
"""
|
||||
request_error: Exception | None = None
|
||||
known_dfu: set[str] | None = None
|
||||
try:
|
||||
async with _connect(address) as client:
|
||||
services = {str(service.uuid).lower() for service in client.services}
|
||||
if DFU_SERVICE_UUID in services:
|
||||
return address
|
||||
if TRIGGER_SERVICE_UUID not in services:
|
||||
raise EsphomeError(
|
||||
f"{address} does not offer an OTA service. Put the device "
|
||||
"into DFU mode and try again."
|
||||
)
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
# macOS hides MAC addresses and gives the bootloader its own UUID
|
||||
known_dfu = await _dfu_devices()
|
||||
_LOGGER.info("Requesting DFU mode from %s...", address)
|
||||
try:
|
||||
await client.write_gatt_char(
|
||||
TRIGGER_CHARACTERISTIC_UUID, TRIGGER_MAGIC, response=True
|
||||
)
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
# The application reboots as soon as the write is handled, so a
|
||||
# disconnect while waiting for the response is expected. Windows
|
||||
# reports that as a cancelled operation (OSError), not a BleakError.
|
||||
# The error is only reported if the bootloader then does not show
|
||||
# up, except an error response (find_dfu_bootloader).
|
||||
_LOGGER.debug("DFU mode request ended with %s", err)
|
||||
request_error = err
|
||||
except _DeviceNotFoundError:
|
||||
_LOGGER.info("%s was not found, looking for its DFU bootloader", address)
|
||||
return await find_dfu_bootloader(address, request_error, known_dfu)
|
||||
|
||||
|
||||
async def _find_new_dfu_devices(
|
||||
known_dfu: set[str], app_address: str
|
||||
) -> tuple[list[str], bool]:
|
||||
"""Scan for DFU devices that are not in ``known_dfu``.
|
||||
|
||||
Also reports whether ``app_address`` still advertised after one appeared: an application that really restarted
|
||||
into its bootloader stops advertising, so a new DFU device seen while it keeps going belongs to another board.
|
||||
"""
|
||||
known = {address.lower() for address in known_dfu}
|
||||
app = app_address.lower()
|
||||
new: dict[str, str] = {}
|
||||
appeared = asyncio.Event()
|
||||
app_still_advertising = False
|
||||
|
||||
def _on_advertisement(device: BLEDevice, advertisement: AdvertisementData) -> None:
|
||||
nonlocal app_still_advertising
|
||||
key = device.address.lower()
|
||||
if key == app:
|
||||
app_still_advertising = app_still_advertising or appeared.is_set()
|
||||
return
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement) and key not in known:
|
||||
new.setdefault(key, device.address)
|
||||
appeared.set()
|
||||
|
||||
async with BleakScanner(detection_callback=_on_advertisement):
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(appeared.wait(), _REBOOT_TIMEOUT)
|
||||
if appeared.is_set():
|
||||
await asyncio.sleep(_NEW_DEVICE_SETTLE)
|
||||
return list(new.values()), app_still_advertising
|
||||
|
||||
|
||||
async def _is_advertising(address: str) -> bool:
|
||||
"""Whether ``address`` shows up in a fresh scan session."""
|
||||
wanted = address.lower()
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
lambda device, _advertisement: device.address.lower() == wanted,
|
||||
timeout=_APP_CHECK_TIMEOUT,
|
||||
)
|
||||
return device is not None
|
||||
|
||||
|
||||
def _raise_if_request_rejected(address: str, request_error: Exception | None) -> None:
|
||||
"""Raise if the application answered the DFU mode request with an error.
|
||||
|
||||
An error response comes from the running application, so it did not
|
||||
restart. A dropped link stays acceptable, as the reboot can look like that.
|
||||
"""
|
||||
if not isinstance(request_error, BleakGATTProtocolError):
|
||||
return
|
||||
message = f"{address} rejected the DFU mode request: {request_error.args[-1]}."
|
||||
if request_error.code in _PAIRING_ERRORS:
|
||||
message += " Make sure the device is paired."
|
||||
raise EsphomeError(message) from request_error
|
||||
|
||||
|
||||
async def find_dfu_bootloader(
|
||||
address: str,
|
||||
request_error: Exception | None = None,
|
||||
known_dfu: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Scan for the bootloader of the device that was asked to enter DFU mode.
|
||||
|
||||
Only ``address`` and the address above it are accepted, so a different
|
||||
device that happens to be in DFU mode nearby is never flashed. With
|
||||
``known_dfu`` (the DFU devices seen before the request, for hosts without
|
||||
MAC addresses) the one DFU device that is new is accepted instead, and only
|
||||
if exactly one appeared and the application no longer advertises. A request
|
||||
the application rejected fails at once, without scanning.
|
||||
"""
|
||||
_raise_if_request_rejected(address, request_error)
|
||||
_LOGGER.info("Scanning for the DFU bootloader...")
|
||||
if known_dfu is not None:
|
||||
new, app_still_advertising = await _find_new_dfu_devices(known_dfu, address)
|
||||
if len(new) == 1 and not app_still_advertising:
|
||||
app_still_advertising = await _is_advertising(address)
|
||||
if not app_still_advertising:
|
||||
return new[0]
|
||||
if app_still_advertising:
|
||||
_LOGGER.warning(
|
||||
"%s kept advertising, so it did not restart into its bootloader",
|
||||
address,
|
||||
)
|
||||
raise _not_found_error(address, request_error, known_dfu | set(new))
|
||||
wanted = {address.lower()}
|
||||
if (next_address := _next_address(address)) is not None:
|
||||
wanted.add(next_address.lower())
|
||||
others: set[str] = set()
|
||||
|
||||
def _is_bootloader(device: BLEDevice, advertisement: AdvertisementData) -> bool:
|
||||
if DFU_SERVICE_UUID not in _service_uuids(advertisement):
|
||||
return False
|
||||
if device.address.lower() in wanted:
|
||||
return True
|
||||
others.add(device.address)
|
||||
return False
|
||||
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
_is_bootloader, timeout=_REBOOT_TIMEOUT
|
||||
)
|
||||
if device is not None:
|
||||
return device.address
|
||||
raise _not_found_error(address, request_error, others)
|
||||
|
||||
|
||||
def _not_found_error(
|
||||
address: str, request_error: Exception | None, others: set[str]
|
||||
) -> EsphomeError:
|
||||
message = f"The DFU bootloader of {address} was not found."
|
||||
if request_error is not None:
|
||||
message += f" The DFU mode request failed: {request_error}."
|
||||
if others:
|
||||
message += f" Other devices in DFU mode: {', '.join(sorted(others))}."
|
||||
return EsphomeError(f"{message} {BOOTLOADER_HINT}")
|
||||
|
||||
|
||||
async def _write_control(client: BleakClient, op: int, *payload: int) -> None:
|
||||
await client.write_gatt_char(
|
||||
DFU_CONTROL_POINT_UUID, bytes([op, *payload]), response=True
|
||||
)
|
||||
|
||||
|
||||
async def _stream_firmware(
|
||||
client: BleakClient, session: _DfuSession, image: bytes
|
||||
) -> None:
|
||||
# Every data packet must be whole words; the image size is too, so the last
|
||||
# packet is as well.
|
||||
chunk_size = max(20, min(client.mtu_size - 3, _MAX_CHUNK_SIZE))
|
||||
chunk_size -= chunk_size % _WORD_SIZE
|
||||
_LOGGER.debug("DFU uses %d byte chunks for %d bytes", chunk_size, len(image))
|
||||
progress = ProgressBar("Uploading")
|
||||
progress.update(0)
|
||||
try:
|
||||
for packet, offset in enumerate(range(0, len(image), chunk_size), start=1):
|
||||
session.raise_if_rejected("upload")
|
||||
chunk = image[offset : offset + chunk_size]
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, chunk, response=False)
|
||||
progress.update((offset + len(chunk)) / len(image))
|
||||
# The packet that completes the image is answered with the
|
||||
# RECEIVE_FIRMWARE_IMAGE response instead of a receipt.
|
||||
if packet % _PRN == 0 and offset + len(chunk) < len(image):
|
||||
await session.wait_for_receipts(packet // _PRN, "upload")
|
||||
finally:
|
||||
progress.done()
|
||||
|
||||
|
||||
async def _perform_update(
|
||||
client: BleakClient, image: bytes, init_packet: bytes
|
||||
) -> None:
|
||||
session = _DfuSession()
|
||||
await client.start_notify(DFU_CONTROL_POINT_UUID, session.notification_handler)
|
||||
try:
|
||||
_LOGGER.debug("DFU: starting the update")
|
||||
await _write_control(client, _OP_START_DFU, _UPLOAD_MODE_APPLICATION)
|
||||
await asyncio.sleep(_START_DELAY)
|
||||
await client.write_gatt_char(
|
||||
DFU_PACKET_UUID, struct.pack("<III", 0, 0, len(image)), response=False
|
||||
)
|
||||
try:
|
||||
await session.expect_success(
|
||||
_OP_START_DFU, "start", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
except EsphomeError:
|
||||
with suppress(BleakError, OSError):
|
||||
await _write_control(client, _OP_RESET)
|
||||
raise
|
||||
|
||||
_LOGGER.debug("DFU: sending the init packet")
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x00)
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, init_packet, response=False)
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x01)
|
||||
await session.expect_success(_OP_INIT_DFU_PARAMS, "init packet")
|
||||
|
||||
await _write_control(
|
||||
client, _OP_PACKET_RECEIPT_NOTIF_REQ, *struct.pack("<H", _PRN)
|
||||
)
|
||||
_LOGGER.debug("DFU: streaming the image")
|
||||
await _write_control(client, _OP_RECEIVE_FIRMWARE_IMAGE)
|
||||
await _stream_firmware(client, session, image)
|
||||
await session.expect_success(
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE, "upload", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
|
||||
await _write_control(client, _OP_VALIDATE)
|
||||
await session.expect_success(_OP_VALIDATE, "validation")
|
||||
|
||||
# The bootloader resets as soon as it handles this, so the link drops
|
||||
# before the write can be acknowledged (an OSError on Windows). An error
|
||||
# response means it answered and refused.
|
||||
try:
|
||||
await _write_control(client, _OP_ACTIVATE_AND_RESET)
|
||||
except BleakGATTProtocolError as err:
|
||||
raise _DfuRejectedError(
|
||||
f"DFU activation rejected by the bootloader: {err}"
|
||||
) from err
|
||||
except (BleakError, OSError) as err:
|
||||
_LOGGER.debug("DFU activation ended with %s", err)
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.stop_notify(DFU_CONTROL_POINT_UUID)
|
||||
|
||||
|
||||
async def _dfu_attempt(address: str, image: bytes, init_packet: bytes) -> None:
|
||||
"""Run the whole DFU sequence once."""
|
||||
async with _connect(address) as client:
|
||||
try:
|
||||
await _perform_update(client, image, init_packet)
|
||||
except (BleakError, OSError) as err:
|
||||
raise EsphomeError(f"DFU failed: {err}") from err
|
||||
|
||||
|
||||
async def dfu_upload(address: str, package: DfuPackage) -> None:
|
||||
"""Flash ``package`` (from ``load_firmware``) to ``address``."""
|
||||
stale_state_retried = False
|
||||
for attempt in range(1, _ATTEMPTS + 1):
|
||||
_LOGGER.info(
|
||||
"Uploading %s to %s (%d bytes)%s...",
|
||||
package.name,
|
||||
address,
|
||||
len(package.image),
|
||||
f", attempt {attempt}/{_ATTEMPTS}" if attempt > 1 else "",
|
||||
)
|
||||
try:
|
||||
await _dfu_attempt(address, package.image, package.init_packet)
|
||||
except _DfuStaleStateError as err:
|
||||
if stale_state_retried or attempt == _ATTEMPTS:
|
||||
raise
|
||||
stale_state_retried = True
|
||||
_LOGGER.warning(
|
||||
"%s; it was left mid-update and has been reset, retrying", err
|
||||
)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
except _DfuRejectedError:
|
||||
raise
|
||||
except EsphomeError as err:
|
||||
if attempt == _ATTEMPTS:
|
||||
raise
|
||||
_LOGGER.warning("DFU attempt %d/%d failed: %s", attempt, _ATTEMPTS, err)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
else:
|
||||
_LOGGER.info("DFU finished; the device restarts into the new firmware")
|
||||
return
|
||||
@@ -0,0 +1,83 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||
from esphome.components.ota import (
|
||||
BASE_OTA_SCHEMA,
|
||||
CONF_ON_ABORT,
|
||||
CONF_ON_END,
|
||||
CONF_ON_PROGRESS,
|
||||
OTAComponent,
|
||||
ota_to_code,
|
||||
)
|
||||
from esphome.components.zephyr import zephyr_data
|
||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||
from esphome.components.zephyr_ble_server import (
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||
from esphome.core import CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DEPENDENCIES = ["zephyr", "zephyr_ble_server"]
|
||||
|
||||
AdafruitBLEOTAComponent = cg.esphome_ns.namespace("adafruit_ble").class_(
|
||||
"OTAComponent", OTAComponent
|
||||
)
|
||||
|
||||
# The device reboots into the bootloader before the transfer starts, so only
|
||||
# on_begin and on_state_change can fire.
|
||||
_UNSUPPORTED_TRIGGERS = (CONF_ON_PROGRESS, CONF_ON_END, CONF_ON_ERROR, CONF_ON_ABORT)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BASE_OTA_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AdafruitBLEOTAComponent),
|
||||
**{
|
||||
cv.Optional(key): cv.invalid(
|
||||
f"'{key}' never fires with Adafruit BLE OTA: the bootloader "
|
||||
"runs the update after the device restarts into it"
|
||||
)
|
||||
for key in _UNSUPPORTED_TRIGGERS
|
||||
},
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_with_framework(Framework.ZEPHYR),
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# Every Adafruit bootloader variant is named adafruit*
|
||||
bootloader = zephyr_data().get(KEY_BOOTLOADER, "")
|
||||
if not bootloader.startswith(BOOTLOADER_ADAFRUIT):
|
||||
raise cv.Invalid(
|
||||
f"Adafruit BLE OTA requires an Adafruit bootloader, but '{bootloader}' "
|
||||
"is configured. Set 'bootloader:' to an Adafruit one."
|
||||
)
|
||||
server = fv.full_config.get().get(ZEPHYR_BLE_SERVER_DOMAIN) or {}
|
||||
if CONF_ON_NUMERIC_COMPARISON_REQUEST not in server:
|
||||
_LOGGER.warning(
|
||||
"Adafruit BLE OTA without pairing lets any nearby device restart this "
|
||||
"one into the bootloader; add '%s' to '%s' to require pairing",
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# The Adafruit bootloader serves the update itself; the component provides
|
||||
# the BLE service that asks the application to restart into it.
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
# Registered before ota_to_code waits past safe_mode, so safe mode keeps the trigger
|
||||
await cg.register_component(var, config)
|
||||
await ota_to_code(var, config)
|
||||
@@ -0,0 +1,73 @@
|
||||
#include "ota_adafruit_ble.h"
|
||||
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <hal/nrf_power.h>
|
||||
#include <zephyr/bluetooth/gatt.h>
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "adafruit_ble");
|
||||
|
||||
// Written by the upload tool (dfu.py) to ask for a restart into the bootloader,
|
||||
// which then serves the DFU transfer. The bootloader only advertises its own DFU
|
||||
// service while it is in DFU mode, so this service is what identifies the
|
||||
// running application.
|
||||
static constexpr uint8_t DFU_TRIGGER_MAGIC[] = {'D', 'F', 'U'};
|
||||
// GPREGRET value the Adafruit nRF52 bootloader reads to enter OTA (BLE DFU) mode
|
||||
static constexpr uint8_t DFU_GPREGRET_OTA = 0xA8;
|
||||
|
||||
// Matches the MCUmgr BLE transport default: a paired (authenticated) link once pairing is enabled
|
||||
#ifdef CONFIG_BT_SMP
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE_AUTHEN;
|
||||
#else
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE;
|
||||
#endif
|
||||
|
||||
static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ssize_t dfu_trigger_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len,
|
||||
uint16_t offset, uint8_t flags) {
|
||||
if (offset != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
|
||||
}
|
||||
if (len != sizeof(DFU_TRIGGER_MAGIC) || memcmp(buf, DFU_TRIGGER_MAGIC, sizeof(DFU_TRIGGER_MAGIC)) != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
|
||||
}
|
||||
global_ota_component->request_dfu_mode();
|
||||
return len;
|
||||
}
|
||||
|
||||
// Registered by bt_enable(), which zephyr_ble_server calls during its setup.
|
||||
BT_GATT_SERVICE_DEFINE( // NOLINT
|
||||
dfu_trigger_svc,
|
||||
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10001, 0x9c3a, 0x4f5d, 0x8a1b,
|
||||
0x2c3d4e5f6071))),
|
||||
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10002, 0x9c3a, 0x4f5d, 0x8a1b, 0x2c3d4e5f6071)),
|
||||
BT_GATT_CHRC_WRITE, DFU_TRIGGER_PERM, nullptr, dfu_trigger_write, nullptr));
|
||||
|
||||
OTAComponent::OTAComponent() { global_ota_component = this; }
|
||||
|
||||
void OTAComponent::request_dfu_mode() {
|
||||
// Restart from the main loop, so the OTA listeners run there and the write is answered first
|
||||
this->defer([this]() {
|
||||
ESP_LOGI(TAG, "DFU mode requested, restarting into the bootloader");
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address
|
||||
NRF_POWER->GPREGRET = DFU_GPREGRET_OTA;
|
||||
App.reboot();
|
||||
});
|
||||
}
|
||||
|
||||
void OTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Over-The-Air Updates:\n"
|
||||
" bootloader: Adafruit nRF52 (BLE DFU)");
|
||||
}
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
// The update itself is served by the Adafruit bootloader; this component only
|
||||
// hosts the BLE trigger service defined in ota_adafruit_ble.cpp.
|
||||
class OTAComponent : public ota::OTAComponent {
|
||||
public:
|
||||
OTAComponent();
|
||||
void dump_config() override;
|
||||
// Called from the Bluetooth thread when the trigger is written
|
||||
void request_dfu_mode();
|
||||
};
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -1,11 +1,23 @@
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Callable, Coroutine
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
from esphome.components import ble_device_base, bluetooth_connection
|
||||
from esphome.components.ble_device_base import (
|
||||
BT_UUID16_FORMAT as bt_uuid16_format,
|
||||
BT_UUID32_FORMAT as bt_uuid32_format,
|
||||
BT_UUID128_FORMAT as bt_uuid128_format,
|
||||
as_hex,
|
||||
as_reversed_hex_array,
|
||||
bt_uuid,
|
||||
)
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHARACTERISTIC_UUID,
|
||||
@@ -18,15 +30,55 @@ from esphome.const import (
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_VALUE,
|
||||
PLATFORM_ESP32,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client"]
|
||||
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
|
||||
# the esp32 schema/codegen arms: importing those modules registers esp32-only
|
||||
# automations as a side effect, which must not leak into the neutral
|
||||
# platforms' registries (the bluetooth_proxy pattern).
|
||||
|
||||
|
||||
def _legacy_engine() -> bool:
|
||||
"""True when the build uses the legacy raw-gattc engine - one line to
|
||||
flip when esp32 moves to the neutral engine (with
|
||||
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
|
||||
return CORE.is_esp32
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""The engine's closure per platform: the legacy esp32 engine builds on
|
||||
esp32_ble_client plus bluetooth_connection (the shared service-table
|
||||
materializer; its sources compile empty in builds without a neutral
|
||||
node), the neutral engine on the bluetooth_connection backend. The
|
||||
platform-less arm is the union for manifest-resolving tooling."""
|
||||
if _legacy_engine() or CORE.target_platform is None:
|
||||
return ["bluetooth_connection", "esp32_ble_client"]
|
||||
return ["bluetooth_connection"]
|
||||
|
||||
|
||||
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
DOMAIN = "ble_client"
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
{
|
||||
"ble_client.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
# Every framework of every non-esp32 registry platform: a platform
|
||||
# that validates the neutral arm must also compile the neutral engine.
|
||||
"ble_client_gatt.cpp": frameworks_for_platforms(
|
||||
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
@@ -62,16 +114,12 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
ble_client_ns = cg.esphome_ns.namespace("ble_client")
|
||||
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
|
||||
# One codegen class for both engines: the exclusively-gated headers resolve
|
||||
# the name to exactly one C++ definition per build.
|
||||
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
|
||||
BLEClientNode = ble_client_ns.class_("BLEClientNode")
|
||||
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
|
||||
# Triggers
|
||||
BLEClientConnectTrigger = ble_client_ns.class_(
|
||||
"BLEClientConnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
BLEClientDisconnectTrigger = ble_client_ns.class_(
|
||||
"BLEClientDisconnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
BLEClientPasskeyRequestTrigger = ble_client_ns.class_(
|
||||
"BLEClientPasskeyRequestTrigger", automation.Trigger.template(BLEClientNodeConstRef)
|
||||
)
|
||||
@@ -109,62 +157,175 @@ CONF_AUTO_CONNECT = "auto_connect"
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientConnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientDisconnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
esp32_ble.consume_connection_slots(1, "ble_client"),
|
||||
# Keys shared by both engines' schemas.
|
||||
_COMMON_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
# Both engines register these on the client; the Bluedroid-security triggers below stay trigger classes
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _esp32_config_schema() -> cv.All:
|
||||
"""The legacy engine's schema, byte-compatible with what esp32 always had
|
||||
(including the Bluedroid security triggers)."""
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(
|
||||
{
|
||||
# Accepted-but-unused legacy key; not propagated to the
|
||||
# neutral schema.
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_PASSKEY_NOTIFICATION
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _gatt_config_schema(platform: str) -> cv.All:
|
||||
"""The neutral engine's schema: the shared keys plus the hub reference
|
||||
(parsed-advertisement sightings) and the GATT backend declaration.
|
||||
Keyed by platform - the backend fragment differs per platform."""
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
|
||||
bluetooth_connection.gatt_client_schema(platform)
|
||||
),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
|
||||
# this shape, so the neutral arm's ble_hub_id is absent from editor
|
||||
# schemas and the esp32-only keys are advertised on every platform.
|
||||
# The language-schema dumper runs without a platform; expose the
|
||||
# esp32 (legacy-engine) shape.
|
||||
return _esp32_config_schema()
|
||||
if _legacy_engine():
|
||||
return _esp32_config_schema()(config)
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return _gatt_config_schema(CORE.target_platform)(config)
|
||||
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _validate_platform
|
||||
|
||||
CONF_BLE_CLIENT_ID = "ble_client_id"
|
||||
|
||||
|
||||
class BLEClientFeatures(StrEnum):
|
||||
"""Per-platform engine capabilities consumers declare against."""
|
||||
|
||||
# The platform-neutral node interface (on_connected/table + completion
|
||||
# callbacks) - every platform with a ble_client engine.
|
||||
GATT_NODE = "gatt_node"
|
||||
# The raw esp32 GATT client event stream (gattc/gap handlers,
|
||||
# node_state) - the legacy engine only.
|
||||
RAW_GATTC = "raw_gattc"
|
||||
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
|
||||
SECURITY = "security"
|
||||
|
||||
|
||||
def _engine_features() -> set[BLEClientFeatures]:
|
||||
"""Features the validated platform's engine provides."""
|
||||
if _legacy_engine():
|
||||
return {
|
||||
BLEClientFeatures.GATT_NODE,
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
BLEClientFeatures.SECURITY,
|
||||
}
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return {BLEClientFeatures.GATT_NODE}
|
||||
return set()
|
||||
|
||||
|
||||
def requires_feature(
|
||||
feature: BLEClientFeatures, description: str
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator gating a consumer to platforms whose engine provides
|
||||
`feature`, naming the missing capability in the error."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
features = _engine_features()
|
||||
if feature not in features:
|
||||
available = (
|
||||
f"; this platform's engine provides: {', '.join(sorted(features))}"
|
||||
if features
|
||||
else ""
|
||||
)
|
||||
raise cv.Invalid(
|
||||
f"{description} requires the ble_client '{feature}' feature, "
|
||||
f"which {CORE.target_platform} does not provide{available}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# The one choke point for every node component still on the raw esp32 event
|
||||
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
|
||||
# register_gatt_node) lifts it.
|
||||
_legacy_engine_only = requires_feature(
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
"This component drives the raw ESP32 GATT client events and has not "
|
||||
"been migrated to the platform-neutral node interface yet; it",
|
||||
)
|
||||
|
||||
BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient), _legacy_engine_only
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# For node components on the neutral interface: valid wherever ble_client
|
||||
# itself is.
|
||||
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient),
|
||||
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,6 +335,26 @@ async def register_ble_node(var, config):
|
||||
cg.add(parent.register_ble_node(var))
|
||||
|
||||
|
||||
def _request_gatt_node_build() -> None:
|
||||
"""Node storage and the one define meaning "the neutral node surface is
|
||||
compiled in", plus the esp32 bridge/materializer defines."""
|
||||
_request_node_slot()
|
||||
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
|
||||
if _legacy_engine():
|
||||
# Deliberately not ble_device_base.request_gatt_client(): that would
|
||||
# claim a phantom backend slot on combined proxy builds.
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
|
||||
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
|
||||
|
||||
|
||||
async def register_gatt_node(var, config):
|
||||
"""Register a node on the platform-neutral interface (both engines)."""
|
||||
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
|
||||
_request_gatt_node_build()
|
||||
cg.add(parent.register_gatt_node(var))
|
||||
|
||||
|
||||
def new_ble_node_component(
|
||||
config: ConfigType, *args: Any
|
||||
) -> Coroutine[Any, Any, cg.MockObj]:
|
||||
@@ -184,8 +365,8 @@ def new_ble_node_component(
|
||||
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_SERVICE_UUID): bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
@@ -196,25 +377,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
|
||||
}
|
||||
)
|
||||
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -242,6 +432,8 @@ automation.register_simple_action(
|
||||
)
|
||||
async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
# The action registers itself as a neutral node in its constructor.
|
||||
_request_gatt_node_build()
|
||||
var = cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
value = config[CONF_VALUE]
|
||||
@@ -256,38 +448,20 @@ async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
|
||||
cg.add(var.set_value_simple(arr, len(value)))
|
||||
|
||||
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
|
||||
cg.add(
|
||||
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
cg.add(var.set_service_uuid128(uuid128))
|
||||
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_char_uuid16(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid32_format
|
||||
):
|
||||
cg.add(
|
||||
var.set_char_uuid32(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid128_format
|
||||
):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
|
||||
config[CONF_CHARACTERISTIC_UUID]
|
||||
)
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
|
||||
cg.add(var.set_char_uuid128(uuid128))
|
||||
|
||||
return var
|
||||
@@ -341,22 +515,48 @@ automation.register_simple_action(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
|
||||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await esp32_ble_tracker.register_client(var, config)
|
||||
return var
|
||||
|
||||
|
||||
# Sizes the neutral client's node storage; the client itself requests a
|
||||
# baseline slot so the define exists on every build that compiles the engine.
|
||||
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
|
||||
|
||||
|
||||
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
|
||||
# The engine always carries the node surface (the client itself owns the
|
||||
# baseline slot).
|
||||
_request_gatt_node_build()
|
||||
backend = await bluetooth_connection.new_gatt_backend(config)
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_backend(backend))
|
||||
# Sighting-gated connects: the client listens for the peer's parsed
|
||||
# advertisements through the hub.
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if _legacy_engine():
|
||||
var = await _to_code_esp32(config)
|
||||
else:
|
||||
var = await _to_code_gatt(config)
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
|
||||
for conf in config.get(CONF_ON_CONNECT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_DISCONNECT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
for conf in config.get(CONF_ON_PASSKEY_REQUEST, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -1,27 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
// placeholder class for static TAG .
|
||||
class Automation {
|
||||
public:
|
||||
// could be made inline with C++17
|
||||
static const char *const TAG;
|
||||
};
|
||||
|
||||
// Base for nodes that never read the parent's services.
|
||||
// The parent releases its services only once every node reports Established, so a node that never
|
||||
// reports it keeps that memory allocated for the life of the connection.
|
||||
@@ -39,45 +26,6 @@ class BLEClientServicelessNode : public BLEClientNode {
|
||||
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
|
||||
};
|
||||
|
||||
// implement on_connect automation.
|
||||
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->trigger();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// on_disconnect automation
|
||||
class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
// test for CLOSE and not DISCONNECT - DISCONNECT can occur even if no virtual connection (OPEN event) occurred.
|
||||
// So this will not trigger unless a complete open has previously succeeded.
|
||||
switch (event) {
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
this->trigger();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
@@ -110,144 +58,6 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
|
||||
}
|
||||
};
|
||||
|
||||
// implement the ble_client.ble_write action.
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
// Static mode: write directly from flash pointer
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
// Template mode: call function and write the vector
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value);
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
|
||||
* macros in header files (Can't even write it in a comment!)
|
||||
* Not sure why, because they seem to work just fine.
|
||||
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
|
||||
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
|
||||
* errors.
|
||||
*/
|
||||
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (this->node_state != espbt::ClientState::ESTABLISHED) {
|
||||
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
esp_err_t err =
|
||||
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
|
||||
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
switch (event) {
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
// upstream code checked the MAC address, verify the characteristic.
|
||||
if (param->write.handle == this->char_handle_)
|
||||
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
break;
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
break;
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
char service_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
|
||||
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
|
||||
break;
|
||||
}
|
||||
this->char_handle_ = chr->handle;
|
||||
this->char_props_ = chr->properties;
|
||||
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
|
||||
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
|
||||
} else {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
break;
|
||||
}
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
ble_client_->address_str());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
espbt::ESPBTUUID service_uuid_;
|
||||
espbt::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
esp_gatt_char_prop_t char_props_{};
|
||||
esp_gatt_write_type_t write_type_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Neutral twins of the shared ble_client automations. Class names, namespace,
|
||||
// and codegen-visible signatures are IDENTICAL to automation.h so generated
|
||||
// main.cpp compiles against whichever engine the build gates in; only the
|
||||
// internals differ (client callbacks and the neutral node interface instead
|
||||
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
|
||||
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include <tuple>
|
||||
|
||||
#include "ble_client_gatt.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientConnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
ble_client->add_on_connect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
// A connect attempt that dies (or a later disconnect) terminates the
|
||||
// chain - unless a fresh attempt is already in flight, meaning a user
|
||||
// trigger on this same list started this action (an on_disconnect that
|
||||
// reconnects) and the wait belongs to that attempt. Legacy split these:
|
||||
// it aborted on DISCONNECT_EVT, the trigger fired on the later CLOSE_EVT.
|
||||
auto terminate = [this]() {
|
||||
if (this->num_running_ != 0 && this->ble_client_->idle())
|
||||
this->stop_complex();
|
||||
};
|
||||
ble_client->add_on_connect_failed_callback(terminate);
|
||||
ble_client->add_on_disconnect_callback(terminate);
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
// it makes no sense to have multiple instances of this running at the
|
||||
// same time; cancel a re-trigger while still running.
|
||||
if (this->num_running_ != 0) {
|
||||
this->stop_complex();
|
||||
return;
|
||||
}
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->connected()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
// No-op while already connecting; the callback resolves the wait.
|
||||
this->ble_client_->connect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientDisconnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
// Both terminal outcomes resolve the wait: a completed teardown and a
|
||||
// connect attempt that died on the way down.
|
||||
ble_client->add_on_disconnect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
ble_client->add_on_connect_failed_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->idle()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
this->ble_client_->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -2,10 +2,16 @@
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
@@ -30,6 +36,10 @@ void BLEClient::dump_config() {
|
||||
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
|
||||
if (!this->enabled)
|
||||
return false;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
|
||||
return false;
|
||||
#endif
|
||||
return BLEClientBase::parse_device(device);
|
||||
}
|
||||
|
||||
@@ -40,24 +50,65 @@ void BLEClient::set_enabled(bool enabled) {
|
||||
if (!enabled) {
|
||||
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// A re-enable clears the backoff (neutral-engine parity).
|
||||
this->gatt_backoff_.reset();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
|
||||
// its automatic CCCD write would double the node's own.
|
||||
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
|
||||
// are unsupported during the migration window.
|
||||
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
|
||||
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
|
||||
if (this->pending_notify_regs_ > 0)
|
||||
this->pending_notify_regs_--;
|
||||
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
|
||||
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
|
||||
// A retiring last registration must still release the cache.
|
||||
this->maybe_release_services_();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
|
||||
return false;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
|
||||
if (!this->gatt_nodes_.empty()) {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
|
||||
// A failed discovery tears the link down; the on_connect trigger must
|
||||
// not fire into the teardown.
|
||||
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
|
||||
return true;
|
||||
} else {
|
||||
this->dispatch_gatt_event_(event, param);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
for (auto *node : this->nodes_)
|
||||
node->gattc_event_handler(event, esp_gattc_if, param);
|
||||
// The automations run after the nodes; on_connect runs before the release so it can still use the services
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
|
||||
this->connect_callbacks_.call();
|
||||
this->maybe_release_services_();
|
||||
if (event == ESP_GATTC_CLOSE_EVT)
|
||||
this->disconnect_callbacks_.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::maybe_release_services_() {
|
||||
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
|
||||
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
|
||||
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
|
||||
this->release_services();
|
||||
ESP_LOGD(TAG, "All clients established, services released");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
@@ -65,10 +116,19 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
|
||||
|
||||
for (auto *node : this->nodes_)
|
||||
node->gap_event_handler(event, param);
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
|
||||
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BLEClient::set_state(espbt::ClientState state) {
|
||||
BLEClientBase::set_state(state);
|
||||
// ESTABLISHED never flows through here; gatt nodes are promoted after the
|
||||
// on_connected fan-out.
|
||||
for (auto &node : nodes_)
|
||||
node->node_state = state;
|
||||
}
|
||||
@@ -83,6 +143,221 @@ bool BLEClient::all_nodes_established_() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
void BLEClient::register_gatt_node(BLEClientNode *node) {
|
||||
// Parent before the capacity check so a dropped node still has a usable
|
||||
// parent() (neutral-engine parity).
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->gatt_nodes_.push_back(node);
|
||||
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
|
||||
// fan-out subset.
|
||||
this->register_ble_node(node);
|
||||
}
|
||||
|
||||
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
|
||||
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
|
||||
if (this->pending_gatt_regs_[i] == handle)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
|
||||
int i = this->find_pending_gatt_reg_(handle);
|
||||
if (i < 0)
|
||||
return false;
|
||||
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
|
||||
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
|
||||
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
case ESP_GATTC_READ_DESCR_EVT: {
|
||||
bool ok = param->read.status == ESP_GATT_OK;
|
||||
if (!ok) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
|
||||
param->read.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
|
||||
ok ? 0 : param->read.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
case ESP_GATTC_WRITE_DESCR_EVT:
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
|
||||
param->write.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_NOTIFY_EVT:
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
|
||||
// The base does no CCCD work for unregister; no interception needed.
|
||||
this->notify_state_to_gatt_nodes_(
|
||||
param->unreg_for_notify.handle, false,
|
||||
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
|
||||
// The base ignores the search status; the neutral contract must not.
|
||||
uint16_t service_total = 0;
|
||||
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
|
||||
this->gattc_if_, this->conn_id_, &service_total);
|
||||
if (!counted || service_total == 0) {
|
||||
// A failed search poisons the whole discovery, legacy nodes included.
|
||||
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Stack-owned; nodes copy their handles during on_connected().
|
||||
bluetooth_connection::BluedroidServiceTable table;
|
||||
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
|
||||
if (!this->has_legacy_nodes_()) {
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Only the table build failed; legacy nodes read the base's services_
|
||||
// and keep the link. Gatt nodes catch the next connection.
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
|
||||
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
|
||||
// A peer whose database never walks must not retry the build at
|
||||
// advertisement cadence once the legacy nodes drop the link.
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
} else {
|
||||
this->gatt_connected_ = true;
|
||||
auto view = table.view();
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_connected(view);
|
||||
if (this->state() != espbt::ClientState::ESTABLISHED) {
|
||||
// The node tore the link down; remaining nodes get on_disconnected
|
||||
// with no preceding on_connected, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->gatt_backoff_.reset();
|
||||
this->status_clear_warning();
|
||||
}
|
||||
// Promote so the legacy release condition can fire.
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->node_state = espbt::ClientState::ESTABLISHED;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::on_disconnect_complete(esp_err_t reason) {
|
||||
this->pending_gatt_reg_count_ = 0;
|
||||
if (!this->gatt_connected_)
|
||||
return; // Never-established links report nothing (neutral parity).
|
||||
this->gatt_connected_ = false;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_disconnected();
|
||||
}
|
||||
|
||||
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
|
||||
if (err != ESP_OK)
|
||||
this->log_gattc_warning_(operation, err);
|
||||
return err;
|
||||
}
|
||||
|
||||
int BLEClient::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;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char",
|
||||
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::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 BLEClient::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 BLEClient::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),
|
||||
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
if (enable) {
|
||||
if (this->find_pending_gatt_reg_(handle) >= 0) {
|
||||
// ESP_OK: the in-flight registration's completion fans out to all nodes.
|
||||
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
|
||||
// An untracked registration would let the base's auto-CCCD through.
|
||||
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
// The base helper's pending count holds the service-release until the
|
||||
// (intercepted) completion.
|
||||
esp_err_t err = this->register_for_notify(handle);
|
||||
if (err == ESP_OK)
|
||||
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
|
||||
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
|
||||
}
|
||||
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
|
||||
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
|
||||
}
|
||||
|
||||
int BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -1,18 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <esp_bt_defs.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gatt_common_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
@@ -21,34 +23,6 @@ namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
using namespace esp32_ble_client;
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {};
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
void set_address(uint64_t address) { address_ = address; }
|
||||
espbt::ESPBTClient *client;
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
espbt::ClientState node_state;
|
||||
|
||||
BLEClient *parent() { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_;
|
||||
uint64_t address_;
|
||||
};
|
||||
|
||||
class BLEClient final : public BLEClientBase {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -64,19 +38,75 @@ class BLEClient final : public BLEClientBase {
|
||||
void set_enabled(bool enabled);
|
||||
|
||||
void register_ble_node(BLEClientNode *node) {
|
||||
node->client = this;
|
||||
node->set_ble_client_parent(this);
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
|
||||
// on_connect fires once discovery completes; on_disconnect on CLOSE_EVT, so only after a completed open
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
bool enabled;
|
||||
|
||||
void set_state(espbt::ClientState state) override;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// ---- the neutral node surface (signatures shared with the non-esp32
|
||||
// engine, so nodes on the neutral interface compile against either) ----
|
||||
void register_gatt_node(BLEClientNode *node);
|
||||
|
||||
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
|
||||
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
int read_characteristic(uint16_t handle);
|
||||
int read_descriptor(uint16_t handle);
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
|
||||
/// Local registration only; per the neutral contract the CCCD write is the
|
||||
/// node's job (the legacy auto-CCCD is suppressed for these handles).
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
// pair() comes from BLEClientBase, matching the neutral engine's.
|
||||
int unpair();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool all_nodes_established_();
|
||||
void maybe_release_services_();
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
int check_and_log_error_(const char *operation, esp_err_t err);
|
||||
int find_pending_gatt_reg_(uint16_t handle) const;
|
||||
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
|
||||
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
|
||||
// False = failed discovery: the link comes down and the caller suppresses
|
||||
// the legacy fan-out.
|
||||
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
|
||||
bool take_pending_gatt_reg_(uint16_t handle);
|
||||
void on_disconnect_complete(esp_err_t reason) override;
|
||||
#endif
|
||||
|
||||
std::vector<BLEClientNode *> nodes_;
|
||||
LazyCallbackManager<void()> connect_callbacks_;
|
||||
LazyCallbackManager<void()> disconnect_callbacks_;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Raise if a migrated node needs more concurrent registrations.
|
||||
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
|
||||
|
||||
// Nodes on the neutral surface; fed the translated callbacks and
|
||||
// auto-established after the on_connected fan-out. Every gatt node is
|
||||
// also in nodes_ (registration pushes into both).
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
|
||||
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
|
||||
// Reconnect backoff after materializer failures.
|
||||
ConnectBackoff gatt_backoff_;
|
||||
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
|
||||
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
|
||||
uint8_t pending_gatt_reg_count_{0};
|
||||
// on_connected fan-out started; on_disconnected is owed at teardown.
|
||||
bool gatt_connected_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "ble_client_gatt.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_client");
|
||||
|
||||
void BLEClient::register_ble_node(BLEClientNode *node) {
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
|
||||
void BLEClient::set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
uint8_t mac[6];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
format_mac_addr_upper(mac, this->address_str_);
|
||||
}
|
||||
|
||||
void BLEClient::set_enabled(bool enabled) {
|
||||
if (enabled == this->enabled)
|
||||
return;
|
||||
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
|
||||
this->enabled = enabled;
|
||||
if (!enabled) {
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
// A re-enable clears the backoff; the next sighting connects (legacy
|
||||
// parity: enabling does not itself connect).
|
||||
this->backoff_.reset();
|
||||
}
|
||||
|
||||
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_)
|
||||
return false;
|
||||
// The sighting is the source of truth for the address type.
|
||||
this->address_type_ = device.get_address_type();
|
||||
this->address_type_known_ = true;
|
||||
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
|
||||
return true;
|
||||
if (this->backoff_.holding_off())
|
||||
return true;
|
||||
this->attempt_connect_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::connect() {
|
||||
if (this->state_ != State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// An absent peer can inhibit scanning for the backend's full connect
|
||||
// timeout, so this is worth a breadcrumb - but it is a supported action.
|
||||
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
|
||||
if (!this->address_type_known_) {
|
||||
// Legacy parity: without a sighting the address type defaults to
|
||||
// public, which never matches a random-static peer.
|
||||
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
|
||||
}
|
||||
this->attempt_connect_();
|
||||
}
|
||||
|
||||
void BLEClient::attempt_connect_() {
|
||||
int err = this->backend_->connect(this->address_, this->address_type_);
|
||||
if (err != 0) {
|
||||
// A refused connect never produces a callback: stay idle, charge the
|
||||
// backoff, and resolve any waiting connect action through the failure
|
||||
// path so its chain terminates.
|
||||
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
|
||||
this->state_ = State::CONNECTING;
|
||||
}
|
||||
|
||||
void BLEClient::disconnect() {
|
||||
if (this->state_ == State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
if (this->cancel_requested_) {
|
||||
// The accepted teardown still reports; a refused second gatt_disconnect()
|
||||
// would settle the link twice (phantom connect failure, backoff charge).
|
||||
ESP_LOGD(TAG, "[%s] Disconnect already in flight, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// A deliberate teardown's failure report must not feed the backoff.
|
||||
this->cancel_requested_ = true;
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
// Refused synchronously: backend and client disagree about the link
|
||||
// state. Warn, then settle through the deliberate-cancel path.
|
||||
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
|
||||
this->on_connection_state(false, 0, err);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
|
||||
if (connected) {
|
||||
this->state_ = State::DISCOVERING;
|
||||
int discover_err = this->backend_->discover_services();
|
||||
if (discover_err != 0) {
|
||||
// Synchronous refusal: no discovery completion will follow.
|
||||
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// Deliberate teardown: its report must not charge the backoff again.
|
||||
this->disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
bool was_connected = this->state_ == State::CONNECTED;
|
||||
bool cancelled = this->cancel_requested_;
|
||||
this->cancel_requested_ = false;
|
||||
this->state_ = State::IDLE;
|
||||
if (was_connected) {
|
||||
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_disconnected();
|
||||
}
|
||||
// Continuations leave the backend's event-drain stack first.
|
||||
this->defer([this]() { this->disconnect_callbacks_.call(); });
|
||||
} else {
|
||||
if (cancelled) {
|
||||
// status carries the refusal code when the teardown settled
|
||||
// synchronously; 0 on a backend-completed cancel.
|
||||
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
}
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_service_discovery_done(int error) {
|
||||
if (this->state_ != State::DISCOVERING || this->cancel_requested_) {
|
||||
// Raced a teardown; the terminal report settles the link.
|
||||
ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// The teardown is deliberate: do not charge the backoff again for its
|
||||
// connection report.
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
ble_device_base::GattServiceTable table{};
|
||||
if (!this->nodes_.empty()) {
|
||||
// Materialize only when a node will read it: a client with no nodes
|
||||
// would pay the build/free cycle on every (re)connect for nothing.
|
||||
table = this->backend_->get_service_table();
|
||||
if (table.service_count == 0) {
|
||||
// A failed materialization is indistinguishable from a service-less
|
||||
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
|
||||
// the discovery before CONNECTED so the teardown resolves through
|
||||
// connect_failed, never a spurious on_disconnect.
|
||||
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// CONNECTED before the fan-out so nodes may consult connected() from
|
||||
// their own on_connected().
|
||||
this->state_ = State::CONNECTED;
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_connected(table);
|
||||
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
|
||||
// A node tore the link down mid-fan-out: on_disconnect fires with no
|
||||
// preceding on_connect, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->backend_->release_services();
|
||||
this->backoff_.reset();
|
||||
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
|
||||
this->defer([this]() { this->connect_callbacks_.call(); });
|
||||
}
|
||||
|
||||
void BLEClient::on_write_result(uint16_t handle, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_write_result(handle, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_read_result(handle, data, len, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
// Every node sees every notification and filters by handle (legacy parity).
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify(handle, data, len);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_pairing_result(int status) {
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE Client:\n"
|
||||
" Address: %s\n"
|
||||
" Auto connect: %s",
|
||||
this->address_str_, YESNO(this->auto_connect_));
|
||||
if (this->enabled && this->state_ == State::IDLE) {
|
||||
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,151 @@
|
||||
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
|
||||
//
|
||||
// Compiled on every platform with a GATT backend except esp32, which keeps
|
||||
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
|
||||
// family migrates - the exclusive gates make the same class names resolve to
|
||||
// exactly one definition per build, so codegen is shared.
|
||||
//
|
||||
// Connects are sighting-gated like the legacy engine: the client is a parsed
|
||||
// advertisement listener, captures the peer's address type from the sighting,
|
||||
// and asks the backend to connect only when enabled and idle.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient : public Component,
|
||||
public ble_device_base::ESPBTDeviceListener,
|
||||
public ble_device_base::GattClientListener {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
// Public field for legacy parity (the switch platform republishes it).
|
||||
bool enabled{true};
|
||||
|
||||
void set_backend(ble_device_base::BLEGattConnection *backend) {
|
||||
this->backend_ = backend;
|
||||
backend->set_listener(this);
|
||||
}
|
||||
void set_address(uint64_t address);
|
||||
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
|
||||
void set_enabled(bool enabled);
|
||||
const char *address_str() const { return this->address_str_; }
|
||||
|
||||
void register_ble_node(BLEClientNode *node);
|
||||
// One registration spelling shared with the esp32 engine's bridge.
|
||||
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
|
||||
|
||||
// An accepted teardown already owns the link: the pending report cannot be
|
||||
// a connect. state_ stays put so that report still fires on_disconnect.
|
||||
bool connected() const { return this->state_ == State::CONNECTED && !this->cancel_requested_; }
|
||||
bool idle() const { return this->state_ == State::IDLE; }
|
||||
|
||||
/// Action-initiated connect (no sighting needed; uses the last captured
|
||||
/// address type, public until a sighting arrives). No-op unless idle.
|
||||
void connect();
|
||||
void disconnect();
|
||||
|
||||
/// Legacy-named deferral used by the automation twins: neutral listener
|
||||
/// callbacks run inside the backend's event drain, so automation chain
|
||||
/// continuations must leave that stack first.
|
||||
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
|
||||
|
||||
// Backend ops for nodes and actions - the frozen node-facing surface.
|
||||
// Only write_characteristic has an in-tree caller; subscribing means
|
||||
// notify_characteristic plus a CCCD write_descriptor (the caller's job
|
||||
// per the contract).
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
return this->backend_->write_characteristic(handle, data, len, response);
|
||||
}
|
||||
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
|
||||
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
return this->backend_->write_descriptor(handle, data, len);
|
||||
}
|
||||
int notify_characteristic(uint16_t handle, bool enable) {
|
||||
return this->backend_->notify_characteristic(handle, enable);
|
||||
}
|
||||
int pair() { return this->backend_->pair(); }
|
||||
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
|
||||
|
||||
// Automation callback registration.
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
// Fired when a connect attempt dies before being established; the user
|
||||
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
|
||||
template<typename F> void add_on_connect_failed_callback(F &&callback) {
|
||||
this->connect_failed_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
// ---- ble_device_base::ESPBTDeviceListener ----
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
|
||||
// ---- ble_device_base::GattClientListener ----
|
||||
void on_connection_state(bool connected, uint16_t mtu, int error) override;
|
||||
void on_service_discovery_done(int error) override;
|
||||
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
|
||||
void on_write_result(uint16_t handle, int error) override;
|
||||
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
|
||||
void on_notify_state(uint16_t handle, bool enabled, int error) override;
|
||||
void on_pairing_result(int status) override;
|
||||
|
||||
protected:
|
||||
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
|
||||
|
||||
void attempt_connect_();
|
||||
|
||||
// Group 1: pointers / containers
|
||||
ble_device_base::BLEGattConnection *backend_{nullptr};
|
||||
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
|
||||
|
||||
// Group 2: 8-byte types
|
||||
uint64_t address_{0};
|
||||
|
||||
// Group 3: callback managers (pointer-sized when empty)
|
||||
LazyCallbackManager<void()> connect_callbacks_;
|
||||
LazyCallbackManager<void()> disconnect_callbacks_;
|
||||
LazyCallbackManager<void()> connect_failed_callbacks_;
|
||||
|
||||
// Group 4: 4-byte types
|
||||
// Backoff so an undiscoverable database or a dead peer cannot produce a
|
||||
// battery-draining connect loop.
|
||||
ConnectBackoff backoff_;
|
||||
|
||||
// Group 5: arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
|
||||
// Group 6: 1-byte types
|
||||
State state_{State::IDLE};
|
||||
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
|
||||
// Distinguishes a captured public type from the never-sighted default.
|
||||
bool address_type_known_{false};
|
||||
bool auto_connect_{true};
|
||||
// A user-initiated teardown in flight; its failure report is not a
|
||||
// connect failure and must not feed the backoff.
|
||||
bool cancel_requested_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,68 @@
|
||||
// The single BLEClientNode both ble_client engines share. The neutral
|
||||
// callback surface is the one interface node components build on; the raw
|
||||
// esp32 surface below it remains for components that have not migrated yet.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Neutral surface, delivered by both engines. The table is borrowed: copy
|
||||
// handles during on_connected(). All nodes see all completions; filter by
|
||||
// handle.
|
||||
// A node that disconnects from inside on_connected() aborts the fan-out;
|
||||
// the user's on_disconnect may then fire without a preceding on_connect.
|
||||
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
|
||||
virtual void on_disconnected() {}
|
||||
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
|
||||
// One in-flight registration per handle; its completion fans out to every
|
||||
// node, so a refused duplicate request still sees on_notify_state.
|
||||
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
|
||||
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
|
||||
virtual void on_write_result(uint16_t handle, int error) {}
|
||||
virtual void on_pairing_result(int status) {}
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
// Legacy raw surface; components overriding these need the legacy engine
|
||||
// until migrated to the neutral surface above.
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {}
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
esp32_ble_tracker::ClientState node_state;
|
||||
#endif
|
||||
|
||||
BLEClient *parent() const { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
@@ -0,0 +1,172 @@
|
||||
// The ble_client.ble_write action: a node on the platform-neutral interface,
|
||||
// so one implementation serves both engines (the esp32 bridge and the
|
||||
// neutral engine).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
// One of the two engine headers resolves per build.
|
||||
#include "ble_client.h"
|
||||
#include "ble_client_gatt.h"
|
||||
#include "ble_client_node.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
static const char *const BLE_WRITE_TAG = "ble_client.automation";
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_gatt_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value.data(), value.size());
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so
|
||||
// that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
// Initiate the write; the completion arrives in on_write_result. The
|
||||
// response-less path can complete synchronously inside the call, so the
|
||||
// handle is armed before the backend is touched.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (!this->ble_client_->connected()) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
if (!this->resolved_) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
|
||||
if (err != 0) {
|
||||
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void on_connected(const ble_device_base::GattServiceTable &table) override {
|
||||
// Resolution comes from this table only; a re-discovery must not keep a
|
||||
// stale handle.
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
|
||||
const auto *chr =
|
||||
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
char service_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
|
||||
this->service_uuid_.to_str(service_buf));
|
||||
return;
|
||||
}
|
||||
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
|
||||
this->write_response_ = true;
|
||||
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
|
||||
this->write_response_ = false;
|
||||
} else {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
return;
|
||||
}
|
||||
this->char_handle_ = chr->value_handle;
|
||||
this->resolved_ = true;
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
this->ble_client_->address_str());
|
||||
}
|
||||
|
||||
void on_disconnected() override {
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
}
|
||||
|
||||
void on_write_result(uint16_t handle, int error) override {
|
||||
if (this->num_running_ == 0) {
|
||||
return;
|
||||
}
|
||||
if (!this->resolved_ || handle != this->char_handle_) {
|
||||
// A parked chain waiting on a completion that never matches would
|
||||
// otherwise stall silently until disconnect.
|
||||
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
|
||||
this->char_handle_);
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
// Continue the chain (legacy parity) but leave a breadcrumb.
|
||||
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
|
||||
}
|
||||
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
ble_device_base::ESPBTUUID service_uuid_;
|
||||
ble_device_base::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
bool write_response_{false};
|
||||
bool resolved_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
/// Reconnect backoff after repeated connect/discovery failures, shared by
|
||||
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
|
||||
/// wrap can at worst reinstate one stale hold-off of a minute.
|
||||
class ConnectBackoff {
|
||||
public:
|
||||
bool holding_off() const {
|
||||
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
|
||||
}
|
||||
void register_failure(const char *address_str) {
|
||||
if (this->failures_ < MAX_STEPS)
|
||||
this->failures_++;
|
||||
this->start_ = now();
|
||||
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
|
||||
}
|
||||
void reset() { this->failures_ = 0; }
|
||||
|
||||
private:
|
||||
// ~10 s per consecutive failure, capped so a flapping peer retries within
|
||||
// a minute at worst.
|
||||
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
|
||||
static constexpr uint8_t MAX_STEPS = 6;
|
||||
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
|
||||
|
||||
uint16_t start_{0};
|
||||
uint8_t failures_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "ble_gatt_client.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_device_base");
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid) {
|
||||
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
|
||||
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
|
||||
if (end > table.characteristic_count) {
|
||||
ESP_LOGW(TAG, "characteristic range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = service.first_characteristic; i < end; i++) {
|
||||
if (table.characteristics[i].uuid == uuid)
|
||||
return &table.characteristics[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid) {
|
||||
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
|
||||
if (end > table.descriptor_count) {
|
||||
// Corrupt range, not a missing descriptor.
|
||||
ESP_LOGW(TAG, "descriptor range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
|
||||
if (table.descriptors[i].uuid == uuid)
|
||||
return &table.descriptors[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
|
||||
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
|
||||
return desc != nullptr ? desc->handle : 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -11,13 +11,16 @@
|
||||
// interface. All listener calls are delivered on the ESPHome main loop;
|
||||
// borrowed data pointers are valid only for the duration of the call.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// Error domain (plain int, forwarded to the API without translation, so the
|
||||
// values are wire-frozen - API clients interpret them):
|
||||
// 0 success
|
||||
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
|
||||
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
|
||||
// native error codes land in this window must remap them out
|
||||
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
|
||||
// ESP_FAIL from the stack shares this value; both read as a
|
||||
// failed, unusable connection on the client side)
|
||||
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
|
||||
// -1..-15 reserved for future contract sentinels
|
||||
// anything else: platform stack error/status code, surfaced opaquely.
|
||||
// Connection events carry HCI status/disconnect reason codes (same code
|
||||
// space on every controller).
|
||||
@@ -99,9 +102,23 @@ class GattClientListener {
|
||||
// The BLEGattConnection op surface, asserted where the alias binds
|
||||
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
|
||||
// through the listener) or a synchronous error (busy, not connected, stack
|
||||
// rejection); one operation may be outstanding at a time. Semantics beyond
|
||||
// the signatures:
|
||||
// rejection); one operation may be outstanding at a time. A connection-state
|
||||
// or discovery completion always arrives from the event loop, never
|
||||
// synchronously from inside the op call - a synchronous terminal
|
||||
// on_connection_state from within gatt_disconnect() would re-enter the
|
||||
// consumer mid-teardown. A local-only completion may instead report inline,
|
||||
// before the op returns: notify_characteristic (a subscription-list edit on
|
||||
// the rp2 backend) and a write without response whose payload the stack took
|
||||
// immediately. Consumers must therefore arm whatever state their
|
||||
// on_write_result/on_notify_state reads before issuing the op, never after
|
||||
// the call returns. Semantics beyond the signatures:
|
||||
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
|
||||
// Returning 0 means the request is accepted, not that the radio acted: the
|
||||
// backend owns integration with its platform's scan/connect arbitration
|
||||
// (Bluedroid parks the request for the tracker's promote loop, which owns
|
||||
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
|
||||
// and relies on sighting-gated consumers). Consumers must not assume
|
||||
// connect timing.
|
||||
// - 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
|
||||
@@ -143,6 +160,41 @@ 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;
|
||||
|
||||
// Characteristic property bits (the Bluetooth-spec declaration byte carried
|
||||
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
|
||||
// exist on the other platforms).
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
/// Handle of the characteristic's Client Characteristic Configuration
|
||||
/// descriptor (0x2902), or 0 when it has none.
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -0,0 +1,213 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_DATA,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_ON_MESSAGE,
|
||||
CONF_PAYLOAD,
|
||||
CONF_VALUE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@bogdanr"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "ble_midi"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_CONTROL_NUMBER = "control_number"
|
||||
CONF_MAX_SYSEX_SIZE = "max_sysex_size"
|
||||
CONF_NOTE = "note"
|
||||
CONF_ON_CONTROL_CHANGE = "on_control_change"
|
||||
CONF_ON_NOTE_OFF = "on_note_off"
|
||||
CONF_ON_NOTE_ON = "on_note_on"
|
||||
CONF_ON_PITCH_BEND = "on_pitch_bend"
|
||||
CONF_ON_PROGRAM_CHANGE = "on_program_change"
|
||||
CONF_ON_SYSEX = "on_sysex"
|
||||
CONF_PAIR = "pair"
|
||||
CONF_PROGRAM = "program"
|
||||
CONF_SYSEX_BUFFER_ID = "sysex_buffer_id"
|
||||
CONF_VELOCITY = "velocity"
|
||||
|
||||
ble_midi_ns = cg.esphome_ns.namespace("ble_midi")
|
||||
BLEMidi = ble_midi_ns.class_("BLEMidi", cg.Component, ble_client.BLEClientNode)
|
||||
# The message bytes as a view: data.size() and data[i] work, and nothing is copied
|
||||
ByteSpan = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
# MIDI data bytes are 7-bit and channels are 4-bit, matching what goes on the wire and what the triggers report.
|
||||
# Channel 0 is the channel that MIDI hardware usually labels "1".
|
||||
midi_data_byte = cv.int_range(min=0, max=127)
|
||||
midi_channel = cv.int_range(min=0, max=15)
|
||||
|
||||
_NOTE_ARGS = [
|
||||
(cg.uint8, CONF_NOTE),
|
||||
(cg.uint8, CONF_VELOCITY),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
]
|
||||
_BYTES_ARGS = [(ByteSpan, CONF_DATA)]
|
||||
# data points into a notification or a reused buffer, so these automations cannot wait
|
||||
_BYTES_TRIGGERS = (CONF_ON_SYSEX, CONF_ON_MESSAGE)
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_ON, "add_on_note_on_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_OFF, "add_on_note_off_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_CONTROL_CHANGE,
|
||||
"add_on_control_change_callback",
|
||||
[
|
||||
(cg.uint8, CONF_CONTROL_NUMBER),
|
||||
(cg.uint8, CONF_VALUE),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PROGRAM_CHANGE,
|
||||
"add_on_program_change_callback",
|
||||
[(cg.uint8, CONF_PROGRAM), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PITCH_BEND,
|
||||
"add_on_pitch_bend_callback",
|
||||
[(cg.int16, CONF_VALUE), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_SYSEX, "add_on_sysex_callback", _BYTES_ARGS),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_MESSAGE, "add_on_message_callback", _BYTES_ARGS
|
||||
),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEMidi),
|
||||
cv.GenerateID(CONF_SYSEX_BUFFER_ID): cv.declare_id(cg.uint8),
|
||||
cv.Optional(CONF_MAX_SYSEX_SIZE, default=256): cv.int_range(
|
||||
min=8, max=2048
|
||||
),
|
||||
cv.Optional(CONF_PAIR, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(entry.conf_key): (
|
||||
automation.validate_synchronous_automation(
|
||||
f"ble_midi {entry.conf_key}", "data"
|
||||
)
|
||||
if entry.conf_key in _BYTES_TRIGGERS
|
||||
else automation.validate_automation({})
|
||||
)
|
||||
for entry in _CALLBACK_AUTOMATIONS
|
||||
},
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
size = config[CONF_MAX_SYSEX_SIZE]
|
||||
# SysEx messages are reassembled in a static array, so nothing is allocated at run time
|
||||
buf = config[CONF_SYSEX_BUFFER_ID].id
|
||||
cg.add_global(cg.RawStatement(f"static uint8_t {buf}[{size}];"))
|
||||
var = await ble_client.new_ble_node_component(
|
||||
config, cg.RawExpression(buf), size, config[CONF_PAIR]
|
||||
)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
def _action_schema(keys: dict) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
**keys,
|
||||
cv.Optional(CONF_CHANNEL, default=0): cv.templatable(midi_channel),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_NOTE_ACTION_SCHEMA = _action_schema(
|
||||
{
|
||||
cv.Required(CONF_NOTE): cv.templatable(midi_data_byte),
|
||||
cv.Optional(CONF_VELOCITY, default=64): cv.templatable(midi_data_byte),
|
||||
}
|
||||
)
|
||||
|
||||
# Wider than the wire values so the C++ range check sees a lambda's result before it is narrowed
|
||||
_CHANNEL = (CONF_CHANNEL, cg.int32)
|
||||
_NOTE_CALL_ARGS = ((CONF_NOTE, cg.int32), (CONF_VELOCITY, cg.int32), _CHANNEL)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_on",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_on({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_off",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_off({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_control_change",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_CONTROL_NUMBER): cv.templatable(midi_data_byte),
|
||||
cv.Required(CONF_VALUE): cv.templatable(midi_data_byte),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_control_change({}, {}, {})",
|
||||
((CONF_CONTROL_NUMBER, cg.int32), (CONF_VALUE, cg.int32), _CHANNEL),
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_program_change",
|
||||
_action_schema({cv.Required(CONF_PROGRAM): cv.templatable(midi_data_byte)}),
|
||||
automation.ApplyCall(
|
||||
"send_program_change({}, {})", ((CONF_PROGRAM, cg.int32), _CHANNEL)
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_pitch_bend",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.int_range(min=-8192, max=8191)),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall("send_pitch_bend({}, {})", ((CONF_VALUE, cg.int32), _CHANNEL)),
|
||||
)
|
||||
|
||||
|
||||
def _bytes_action_schema(key: str) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
cv.Required(key): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_sysex",
|
||||
_bytes_action_schema(CONF_PAYLOAD),
|
||||
automation.ApplyBytes(CONF_PAYLOAD, "send_sysex({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_raw",
|
||||
_bytes_action_schema(CONF_DATA),
|
||||
automation.ApplyBytes(CONF_DATA, "send_raw({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_condition(
|
||||
"ble_midi.connected", cv.Schema({cv.GenerateID(): cv.use_id(BLEMidi)}), "is_ready()"
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
#include "ble_midi.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_midi");
|
||||
|
||||
// Standard BLE-MIDI service and characteristic, as defined by the BLE-MIDI
|
||||
// specification.
|
||||
static constexpr const char *SERVICE_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700";
|
||||
static constexpr const char *CHARACTERISTIC_UUID = "7772E5DB-3868-4112-A1A9-F2669D106BF3";
|
||||
|
||||
// MIDI status bytes for the messages this component can transmit.
|
||||
static constexpr uint8_t STATUS_NOTE_OFF = 0x80;
|
||||
static constexpr uint8_t STATUS_NOTE_ON = 0x90;
|
||||
static constexpr uint8_t STATUS_CONTROL_CHANGE = 0xB0;
|
||||
static constexpr uint8_t STATUS_PROGRAM_CHANGE = 0xC0;
|
||||
static constexpr uint8_t STATUS_PITCH_BEND = 0xE0;
|
||||
|
||||
// Number of received bytes included in verbose packet logs.
|
||||
static constexpr size_t LOG_MAX_BYTES = 32;
|
||||
|
||||
void BLEMidi::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE MIDI:\n"
|
||||
" MAC address: %s\n"
|
||||
" Maximum SysEx size: %u\n"
|
||||
" Pair on connect: %s",
|
||||
this->parent()->address_str(), this->parser_.sysex_capacity(), YESNO(this->pair_));
|
||||
}
|
||||
|
||||
void BLEMidi::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (param->open.status != ESP_GATT_OK)
|
||||
break;
|
||||
// A warning left by an earlier session no longer applies
|
||||
this->status_clear_warning();
|
||||
this->parser_.reset();
|
||||
if (this->pair_) {
|
||||
esp_err_t err = this->parent()->pair();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Pairing failed, error=%d", err);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CFG_MTU_EVT: {
|
||||
if (param->cfg_mtu.status == ESP_GATT_OK)
|
||||
this->mtu_ = param->cfg_mtu.mtu;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
this->parser_.reset();
|
||||
this->characteristic_handle_ = 0;
|
||||
this->cccd_handle_ = 0;
|
||||
this->mtu_ = 23;
|
||||
// Both events can arrive for the same disconnect, so the automation runs
|
||||
// only for the one that follows an established MIDI connection.
|
||||
const bool was_ready = this->notify_registered_;
|
||||
this->notify_registered_ = false;
|
||||
this->node_state = espbt::ClientState::IDLE;
|
||||
if (was_ready)
|
||||
this->disconnect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
if (param->search_cmpl.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Service discovery failed, status=%d", param->search_cmpl.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
auto *characteristic = this->parent()->get_characteristic(espbt::ESPBTUUID::from_raw(SERVICE_UUID),
|
||||
espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID));
|
||||
// Reconnecting would not help a device without them, so the link is left as it is
|
||||
if (characteristic == nullptr) {
|
||||
this->status_set_warning(LOG_STR("No BLE MIDI characteristic found on this device"));
|
||||
break;
|
||||
}
|
||||
auto *cccd = characteristic->get_descriptor(ESP_GATT_UUID_CHAR_CLIENT_CONFIG);
|
||||
if (cccd == nullptr) {
|
||||
this->status_set_warning(LOG_STR("BLE MIDI characteristic has no notification descriptor"));
|
||||
break;
|
||||
}
|
||||
this->characteristic_handle_ = characteristic->handle;
|
||||
this->cccd_handle_ = cccd->handle;
|
||||
this->write_without_response_ = (characteristic->properties & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) != 0;
|
||||
esp_err_t err = this->parent()->register_for_notify(characteristic->handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register_for_notify failed, error=%d", err);
|
||||
this->fail_session_();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
if (param->reg_for_notify.handle != this->characteristic_handle_)
|
||||
break;
|
||||
if (param->reg_for_notify.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Registering for notifications failed, status=%d", param->reg_for_notify.status);
|
||||
this->fail_session_();
|
||||
}
|
||||
// On success the parent writes the descriptor; the device has accepted it once WRITE_DESCR_EVT reports OK
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_DESCR_EVT: {
|
||||
if (this->cccd_handle_ == 0 || param->write.handle != this->cccd_handle_ || this->notify_registered_)
|
||||
break;
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Device refused to enable notifications, status=%d", param->write.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
this->notify_registered_ = true;
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
ESP_LOGI(TAG, "MIDI notifications enabled");
|
||||
// The device can only be sent to once notifications are enabled, so this
|
||||
// is the point where on_connect is useful.
|
||||
this->connect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT: {
|
||||
// A write with response reports its outcome here; one without response reports nothing
|
||||
if (param->write.handle == this->characteristic_handle_ && param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Write rejected, status=%d", param->write.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->characteristic_handle_ || param->notify.value_len == 0)
|
||||
break;
|
||||
// format_hex_to() truncates to the buffer size, so long packets are logged
|
||||
// only in part.
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Received %u bytes: %s", param->notify.value_len,
|
||||
format_hex_to(hex, param->notify.value, param->notify.value_len));
|
||||
this->parser_.feed(param->notify.value, param->notify.value_len, BLEMidi::dispatch_message, this);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BLEMidi::fail_session_() {
|
||||
this->status_set_warning();
|
||||
// ble_client reconnects, which runs the setup again
|
||||
this->parent()->disconnect();
|
||||
}
|
||||
|
||||
void BLEMidi::handle_message_(const MidiMessage &message) {
|
||||
ESP_LOGV(
|
||||
TAG, "Decoded %s",
|
||||
LOG_STR_ARG(MessageTypeNames::get_log_str(static_cast<uint8_t>(message.type), MessageTypeNames::LAST_INDEX)));
|
||||
// Only decoded messages reach on_message
|
||||
if (message.type == MessageType::SYSEX_INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "SysEx of %u bytes dropped: cut short, or longer than max_sysex_size (%u)", message.length,
|
||||
this->parser_.sysex_capacity());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "MIDI message 0x%02X dropped: cut short by another status byte", message.status());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::UNKNOWN) {
|
||||
ESP_LOGW(TAG, "Undecodable MIDI byte 0x%02X", message.status());
|
||||
return;
|
||||
}
|
||||
this->message_callback_.call(ByteSpan(message.data, message.length));
|
||||
|
||||
switch (message.type) {
|
||||
case MessageType::NOTE_ON:
|
||||
this->note_on_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::NOTE_OFF:
|
||||
this->note_off_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::CONTROL_CHANGE:
|
||||
this->control_change_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::PROGRAM_CHANGE:
|
||||
this->program_change_callback_.call(message.data1(), message.channel());
|
||||
break;
|
||||
case MessageType::PITCH_BEND:
|
||||
this->pitch_bend_callback_.call(message.pitch_bend(), message.channel());
|
||||
break;
|
||||
case MessageType::SYSEX:
|
||||
// The payload without the 0xF0/0xF7 wrappers; a complete SysEx always has both
|
||||
this->sysex_callback_.call(ByteSpan(message.data + 1, message.length - 2));
|
||||
break;
|
||||
default:
|
||||
// Polyphonic and channel aftertouch, system common and real-time messages
|
||||
// are only reported through on_message.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEMidi::write_packet_(uint8_t *packet, size_t midi_length) {
|
||||
if (!this->is_ready()) {
|
||||
ESP_LOGW(TAG, "Cannot send: not connected");
|
||||
return false;
|
||||
}
|
||||
// Every BLE-MIDI packet has to fit one ATT packet
|
||||
const size_t packet_length = midi_length + 2;
|
||||
const size_t limit = std::min<size_t>(MAX_TX_MIDI_BYTES + 2, this->mtu_ - 3);
|
||||
if (packet_length > limit) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes exceeds the %u byte limit", static_cast<unsigned>(midi_length),
|
||||
static_cast<unsigned>(limit - 2));
|
||||
return false;
|
||||
}
|
||||
packet[0] = PACKET_HEADER;
|
||||
packet[1] = PACKET_TIMESTAMP;
|
||||
esp_err_t err = esp_ble_gattc_write_char(
|
||||
this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->characteristic_handle_, packet_length,
|
||||
packet, this->write_without_response_ ? ESP_GATT_WRITE_TYPE_NO_RSP : ESP_GATT_WRITE_TYPE_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Write failed, error=%d", err);
|
||||
return false;
|
||||
}
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Sent %u bytes: %s", static_cast<unsigned>(packet_length), format_hex_to(hex, packet, packet_length));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BLEMidi::write_midi_(const uint8_t *midi_bytes, size_t length) {
|
||||
if (length == 0 || length > MAX_TX_MIDI_BYTES) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes, the limit is %u", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return false;
|
||||
}
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
std::memcpy(packet + 2, midi_bytes, length);
|
||||
return this->write_packet_(packet, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length) {
|
||||
// Validation covers constants only; a lambda can return any value, and masking it would send a different message.
|
||||
// The values arrive unnarrowed so a negative or wrapped one is caught here (the unsigned compare covers negatives).
|
||||
if (static_cast<uint32_t>(channel) > 0x0F || static_cast<uint32_t>(data1) > 0x7F ||
|
||||
static_cast<uint32_t>(data2) > 0x7F) {
|
||||
ESP_LOGW(TAG, "Cannot send: channel %" PRId32 " or value %" PRId32 "/%" PRId32 " out of range", channel, data1,
|
||||
data2);
|
||||
return;
|
||||
}
|
||||
const uint8_t bytes[3] = {static_cast<uint8_t>(status | channel), static_cast<uint8_t>(data1),
|
||||
static_cast<uint8_t>(data2)};
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_on(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_ON, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_off(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_OFF, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_control_change(int32_t control_number, int32_t value, int32_t channel) {
|
||||
this->send_channel_(STATUS_CONTROL_CHANGE, channel, control_number, value, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_program_change(int32_t program, int32_t channel) {
|
||||
this->send_channel_(STATUS_PROGRAM_CHANGE, channel, program, 0, 2);
|
||||
}
|
||||
|
||||
void BLEMidi::send_pitch_bend(int32_t value, int32_t channel) {
|
||||
if (value < -8192 || value > 8191) {
|
||||
ESP_LOGW(TAG, "Cannot send: pitch bend %" PRId32 " out of range", value);
|
||||
return;
|
||||
}
|
||||
const uint16_t unsigned_value = static_cast<uint16_t>(value + 8192);
|
||||
this->send_channel_(STATUS_PITCH_BEND, channel, unsigned_value & 0x7F, unsigned_value >> 7, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_sysex(const uint8_t *payload, size_t length) {
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
const size_t framed = frame_sysex(payload, length, packet + 2, MAX_TX_MIDI_BYTES);
|
||||
if (framed == 0) {
|
||||
ESP_LOGW(TAG, "Cannot send: SysEx of %u bytes is empty or exceeds the %u byte limit", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return;
|
||||
}
|
||||
this->write_packet_(packet, framed);
|
||||
}
|
||||
|
||||
void BLEMidi::send_raw(const uint8_t *bytes, size_t length) {
|
||||
// The packet carries one timestamp, in front of the first byte, so the payload has to be a single complete
|
||||
// message with its status byte first; a SysEx goes through send_sysex
|
||||
bool one_message = length > 0 && (bytes[0] & 0x80) != 0 && bytes[0] != STATUS_SYSEX_START &&
|
||||
bytes[0] != STATUS_SYSEX_END && length == 1u + MidiParser::expected_data_bytes(bytes[0]);
|
||||
for (size_t i = 1; one_message && i < length; i++) {
|
||||
one_message = (bytes[i] & 0x80) == 0;
|
||||
}
|
||||
if (!one_message) {
|
||||
ESP_LOGW(TAG, "Cannot send: raw payload must be one message, a status byte followed by data bytes");
|
||||
return;
|
||||
}
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,138 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
/// The bytes of a received message, valid for the duration of the automation.
|
||||
using ByteSpan = std::span<const uint8_t>;
|
||||
|
||||
/// Longest MIDI message that can be transmitted in a single BLE-MIDI packet, header and timestamp excluded.
|
||||
static constexpr size_t MAX_TX_MIDI_BYTES = 128;
|
||||
|
||||
/// Client for devices implementing the standard BLE-MIDI service, e.g. wireless
|
||||
/// MIDI keyboards and pad controllers. Incoming messages are exposed as
|
||||
/// automation triggers; outgoing messages are sent with the ble_midi.send_*
|
||||
/// actions.
|
||||
///
|
||||
/// Specification: "Specification for MIDI over Bluetooth Low Energy
|
||||
/// (BLE-MIDI)", MIDI Association, document M1-2017-11-01.
|
||||
class BLEMidi final : public Component, public ble_client::BLEClientNode {
|
||||
public:
|
||||
/// sysex_buffer holds sysex_capacity bytes; longer SysEx messages are truncated.
|
||||
BLEMidi(uint8_t *sysex_buffer, uint16_t sysex_capacity, bool pair)
|
||||
: parser_(sysex_buffer, sysex_capacity), pair_(pair) {}
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
|
||||
/// True once the MIDI characteristic has been discovered and notifications
|
||||
/// are enabled.
|
||||
bool is_ready() const { return this->notify_registered_; }
|
||||
|
||||
// Channel is 0-15, note/velocity/controller/value/program are 0-127; anything else is refused with a warning.
|
||||
void send_note_on(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_note_off(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_control_change(int32_t control_number, int32_t value, int32_t channel);
|
||||
void send_program_change(int32_t program, int32_t channel);
|
||||
/// Pitch bend value is -8192 to 8191, centered on zero.
|
||||
void send_pitch_bend(int32_t value, int32_t channel);
|
||||
/// Send a System Exclusive message. The 0xF0/0xF7 wrappers are added if the
|
||||
/// payload does not already carry them.
|
||||
void send_sysex(const uint8_t *payload, size_t length);
|
||||
/// Send raw MIDI bytes (status byte followed by its data bytes).
|
||||
void send_raw(const uint8_t *bytes, size_t length);
|
||||
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel)
|
||||
template<typename F> void add_on_note_on_callback(F &&callback) {
|
||||
this->note_on_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel); a Note On with velocity zero is reported here as well
|
||||
template<typename F> void add_on_note_off_callback(F &&callback) {
|
||||
this->note_off_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (control_number, value, channel)
|
||||
template<typename F> void add_on_control_change_callback(F &&callback) {
|
||||
this->control_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (program, channel)
|
||||
template<typename F> void add_on_program_change_callback(F &&callback) {
|
||||
this->program_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (value, channel), value being -8192 to 8191
|
||||
template<typename F> void add_on_pitch_bend_callback(F &&callback) {
|
||||
this->pitch_bend_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The SysEx payload without the 0xF0/0xF7 wrappers
|
||||
template<typename F> void add_on_sysex_callback(F &&callback) {
|
||||
this->sysex_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The raw bytes of every decoded message
|
||||
template<typename F> void add_on_message_callback(F &&callback) {
|
||||
this->message_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
static void dispatch_message(void *ctx, const MidiMessage &message) {
|
||||
static_cast<BLEMidi *>(ctx)->handle_message_(message);
|
||||
}
|
||||
void handle_message_(const MidiMessage &message);
|
||||
/// Wrap MIDI bytes in a BLE-MIDI packet and write them to the MIDI characteristic.
|
||||
bool write_midi_(const uint8_t *midi_bytes, size_t length);
|
||||
/// Write a packet whose MIDI bytes already sit at packet + 2; the header and timestamp are filled in.
|
||||
bool write_packet_(uint8_t *packet, size_t midi_length);
|
||||
/// Channel-voice message: the status byte takes the channel; out-of-range values are refused.
|
||||
void send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length);
|
||||
/// Set a warning and drop the link so that ble_client reconnects and the setup runs again.
|
||||
void fail_session_();
|
||||
|
||||
MidiParser parser_;
|
||||
LazyCallbackManager<void()> connect_callback_;
|
||||
LazyCallbackManager<void()> disconnect_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_on_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_off_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> control_change_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t)> program_change_callback_;
|
||||
LazyCallbackManager<void(int16_t, uint8_t)> pitch_bend_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> sysex_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> message_callback_;
|
||||
uint16_t characteristic_handle_{0};
|
||||
// Notification descriptor of the MIDI characteristic; the link is ready once the device accepts the write to it
|
||||
uint16_t cccd_handle_{0};
|
||||
// The ATT MTU the link negotiated; every packet has to fit it
|
||||
uint16_t mtu_{23};
|
||||
bool notify_registered_{false};
|
||||
// Write without response has lower latency and is preferred when the
|
||||
// characteristic supports it.
|
||||
bool write_without_response_{false};
|
||||
const bool pair_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
static MessageType type_for_status(uint8_t status) {
|
||||
if (status >= 0xF8)
|
||||
return MessageType::SYSTEM_REAL_TIME;
|
||||
if (status >= 0xF1 && status <= 0xF6)
|
||||
return MessageType::SYSTEM_COMMON;
|
||||
switch (status & 0xF0) {
|
||||
case 0x80:
|
||||
return MessageType::NOTE_OFF;
|
||||
case 0x90:
|
||||
return MessageType::NOTE_ON;
|
||||
case 0xA0:
|
||||
return MessageType::POLY_AFTERTOUCH;
|
||||
case 0xB0:
|
||||
return MessageType::CONTROL_CHANGE;
|
||||
case 0xC0:
|
||||
return MessageType::PROGRAM_CHANGE;
|
||||
case 0xD0:
|
||||
return MessageType::CHANNEL_AFTERTOUCH;
|
||||
case 0xE0:
|
||||
return MessageType::PITCH_BEND;
|
||||
default:
|
||||
return MessageType::UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// Data bytes that follow a channel-voice or system-common status byte
|
||||
uint8_t MidiParser::expected_data_bytes(uint8_t status) {
|
||||
if (status >= 0xF0) {
|
||||
switch (status) {
|
||||
case 0xF1: // MIDI Time Code Quarter Frame
|
||||
case 0xF3: // Song Select
|
||||
return 1;
|
||||
case 0xF2: // Song Position Pointer
|
||||
return 2;
|
||||
default: // 0xF4, 0xF5 undefined, 0xF6 Tune Request
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
switch (status & 0xF0) {
|
||||
case 0xC0: // Program Change
|
||||
case 0xD0: // Channel Aftertouch
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
void MidiParser::reset() {
|
||||
this->running_status_ = 0;
|
||||
this->sysex_active_ = false;
|
||||
this->sysex_length_ = 0;
|
||||
this->current_length_ = 0;
|
||||
}
|
||||
|
||||
void MidiParser::sysex_push_(uint8_t byte) {
|
||||
if (this->sysex_length_ < this->sysex_capacity_) {
|
||||
this->sysex_buffer_[this->sysex_length_++] = byte;
|
||||
} else {
|
||||
this->sysex_overflow_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ends the SysEx being assembled; it is complete when 0xF7 closed it and every byte fit the buffer
|
||||
void MidiParser::end_sysex_(bool complete, MidiMessageHandler handler, void *ctx) {
|
||||
this->sysex_active_ = false;
|
||||
this->running_status_ = 0;
|
||||
this->emit_(complete && !this->sysex_overflow_ ? MessageType::SYSEX : MessageType::SYSEX_INCOMPLETE,
|
||||
this->sysex_buffer_, this->sysex_length_, handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::emit_current_(MidiMessageHandler handler, void *ctx) {
|
||||
const uint8_t status = this->current_message_[0];
|
||||
MessageType type = type_for_status(status);
|
||||
// A Note On with velocity zero is canonically a Note Off.
|
||||
if (type == MessageType::NOTE_ON && this->current_message_[2] == 0)
|
||||
type = MessageType::NOTE_OFF;
|
||||
// System Common clears running status; channel-voice messages set it.
|
||||
this->running_status_ = status >= 0xF0 ? 0 : status;
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(type, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
|
||||
// Takes a data byte for the message being assembled, or starts one from the running status
|
||||
void MidiParser::take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx) {
|
||||
if (this->current_length_ == 0) {
|
||||
if (this->running_status_ == 0) {
|
||||
// Data byte without any status context.
|
||||
this->emit_(MessageType::UNKNOWN, byte, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
this->current_message_[0] = this->running_status_;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(this->running_status_);
|
||||
}
|
||||
this->current_message_[this->current_length_++] = *byte;
|
||||
if (this->current_length_ > this->current_expected_)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx) {
|
||||
// A BLE-MIDI packet is at least a header byte plus one timestamp or SysEx
|
||||
// continuation byte, and the header byte always has bit 7 set. Anything else
|
||||
// is malformed.
|
||||
if (length < 2 || (data[0] & 0x80) == 0) {
|
||||
for (size_t i = 0; i < length; i++)
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// A timestamp byte precedes every status byte, even a real-time byte inside another message, so a byte with
|
||||
// bit 7 set is a status byte only right after a timestamp and a timestamp everywhere else
|
||||
bool expect_timestamp = true;
|
||||
for (size_t i = 1; i < length; i++) {
|
||||
const uint8_t byte = data[i];
|
||||
if ((byte & 0x80) == 0) {
|
||||
// A data byte continues the SysEx, the message being assembled, or the running status
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(byte);
|
||||
} else {
|
||||
this->take_data_(data + i, handler, ctx);
|
||||
}
|
||||
expect_timestamp = true;
|
||||
continue;
|
||||
}
|
||||
if (expect_timestamp) {
|
||||
expect_timestamp = false;
|
||||
continue;
|
||||
}
|
||||
expect_timestamp = true;
|
||||
if (byte >= 0xF8) {
|
||||
// Real-time messages are single bytes that interrupt anything, a SysEx included, without affecting it
|
||||
this->emit_(MessageType::SYSTEM_REAL_TIME, data + i, 1, handler, ctx);
|
||||
continue;
|
||||
}
|
||||
// Any other status byte cuts a message still being assembled short
|
||||
if (this->current_length_ > 0) {
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(MessageType::INCOMPLETE, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
if (byte == STATUS_SYSEX_END) {
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(STATUS_SYSEX_END);
|
||||
this->end_sysex_(true, handler, ctx);
|
||||
} else {
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Any status byte but 0xF7 ends a SysEx still open
|
||||
if (this->sysex_active_)
|
||||
this->end_sysex_(false, handler, ctx);
|
||||
if (byte == STATUS_SYSEX_START) {
|
||||
this->sysex_active_ = true;
|
||||
this->sysex_length_ = 0;
|
||||
this->sysex_overflow_ = false;
|
||||
this->sysex_push_(STATUS_SYSEX_START);
|
||||
continue;
|
||||
}
|
||||
this->current_message_[0] = byte;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(byte);
|
||||
// A status byte without data bytes, e.g. Tune Request, is a message on its own
|
||||
if (this->current_expected_ == 0)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size) {
|
||||
if (length == 0)
|
||||
return 0;
|
||||
const size_t skip_start = payload[0] == STATUS_SYSEX_START ? 1 : 0;
|
||||
const size_t skip_end = payload[length - 1] == STATUS_SYSEX_END ? 1 : 0;
|
||||
const size_t body = length - skip_start - skip_end;
|
||||
// 0xF0, the body, then the timestamp before 0xF7
|
||||
const size_t framed = body + 3;
|
||||
if (framed > out_size)
|
||||
return 0;
|
||||
// A body byte with bit 7 set would be read as a timestamp or status byte
|
||||
for (size_t i = 0; i < body; i++) {
|
||||
if (payload[skip_start + i] & 0x80)
|
||||
return 0;
|
||||
}
|
||||
out[0] = STATUS_SYSEX_START;
|
||||
std::memcpy(out + 1, payload + skip_start, body);
|
||||
out[body + 1] = PACKET_TIMESTAMP;
|
||||
out[body + 2] = STATUS_SYSEX_END;
|
||||
return framed;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
// Decoded MIDI message types. Status bytes have the high bit set; the low nibble of a channel-voice status byte
|
||||
// (0x80-0xEF) is the channel, system messages (0xF0-0xFF) have none.
|
||||
enum class MessageType : uint8_t {
|
||||
NOTE_OFF, // 0x80
|
||||
NOTE_ON, // 0x90
|
||||
POLY_AFTERTOUCH, // 0xA0
|
||||
CONTROL_CHANGE, // 0xB0
|
||||
PROGRAM_CHANGE, // 0xC0
|
||||
CHANNEL_AFTERTOUCH, // 0xD0
|
||||
PITCH_BEND, // 0xE0
|
||||
SYSEX, // 0xF0 ... 0xF7, possibly spread over several BLE packets
|
||||
SYSEX_INCOMPLETE, // a SysEx cut short by another status byte or longer than the buffer
|
||||
SYSTEM_COMMON, // 0xF1-0xF6, e.g. quarter frame, song position
|
||||
SYSTEM_REAL_TIME, // 0xF8-0xFF, e.g. clock, start, stop
|
||||
INCOMPLETE, // a channel or system common message cut short by another status byte
|
||||
UNKNOWN, // malformed/undecodable byte
|
||||
};
|
||||
|
||||
PROGMEM_STRING_TABLE(MessageTypeNames, "NOTE_OFF", "NOTE_ON", "POLY_AFTERTOUCH", "CONTROL_CHANGE", "PROGRAM_CHANGE",
|
||||
"CHANNEL_AFTERTOUCH", "PITCH_BEND", "SYSEX", "SYSEX_INCOMPLETE", "SYSTEM_COMMON",
|
||||
"SYSTEM_REAL_TIME", "INCOMPLETE", "UNKNOWN");
|
||||
|
||||
/// A decoded MIDI message. `data` points into the parser's storage or the packet being fed, so it is only valid
|
||||
/// for the duration of the handler.
|
||||
struct MidiMessage {
|
||||
// Status byte followed by its data bytes; a SysEx includes the 0xF0/0xF7 wrappers
|
||||
const uint8_t *data{nullptr};
|
||||
uint16_t length{0};
|
||||
MessageType type{MessageType::UNKNOWN};
|
||||
|
||||
uint8_t status() const { return this->data[0]; }
|
||||
uint8_t channel() const { return this->data[0] & 0x0F; }
|
||||
// The data bytes of a complete channel-voice message: note and velocity, controller and value, or program
|
||||
uint8_t data1() const { return this->data[1]; }
|
||||
uint8_t data2() const { return this->data[2]; }
|
||||
// PITCH_BEND: 14-bit value re-centered on zero (-8192..8191).
|
||||
int16_t pitch_bend() const {
|
||||
return static_cast<int16_t>((static_cast<uint16_t>(this->data[2]) << 7) | this->data[1]) - 8192;
|
||||
}
|
||||
};
|
||||
|
||||
/// Called once per decoded message with the context passed to feed().
|
||||
using MidiMessageHandler = void (*)(void *ctx, const MidiMessage &message);
|
||||
|
||||
/// Decoder for the BLE-MIDI packet format (Apple/MMA "Bluetooth Low Energy
|
||||
/// MIDI" specification, section 3).
|
||||
///
|
||||
/// Packet layout:
|
||||
/// [header] [timestamp] [MIDI 0] ... ([timestamp] [MIDI n])*
|
||||
///
|
||||
/// - header : 0b10hhhhhh, bit 7 set, bits 5-0 hold the high bits of the
|
||||
/// timestamp
|
||||
/// - timestamp : 0b1lllllll, bit 7 set, bits 6-0 hold the low bits of the
|
||||
/// timestamp
|
||||
///
|
||||
/// A timestamp byte precedes every status byte, except where running status
|
||||
/// applies. SysEx messages may span several BLE packets and may be interrupted
|
||||
/// by real-time messages (0xF8-0xFF).
|
||||
///
|
||||
/// Timestamps are used for jitter reconstruction by MIDI hosts and are ignored
|
||||
/// here.
|
||||
///
|
||||
/// This class holds no BLE or ESPHome state so that it can be exercised in
|
||||
/// isolation.
|
||||
class MidiParser final {
|
||||
public:
|
||||
/// `sysex_buffer` reassembles SysEx messages; one longer than `sysex_capacity` bytes is reported incomplete.
|
||||
MidiParser(uint8_t *sysex_buffer, uint16_t sysex_capacity)
|
||||
: sysex_buffer_(sysex_buffer), sysex_capacity_(sysex_capacity) {}
|
||||
|
||||
uint16_t sysex_capacity() const { return this->sysex_capacity_; }
|
||||
|
||||
/// Drop all parser state. Call on disconnect so running status and partial
|
||||
/// messages do not leak into the next session.
|
||||
void reset();
|
||||
|
||||
/// Feed one BLE notification payload. Undecodable bytes are reported as MessageType::UNKNOWN so the
|
||||
/// caller can log them.
|
||||
void feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx);
|
||||
|
||||
/// Number of data bytes that follow a status byte other than SysEx.
|
||||
static uint8_t expected_data_bytes(uint8_t status);
|
||||
|
||||
protected:
|
||||
void emit_(MessageType type, const uint8_t *data, uint16_t length, MidiMessageHandler handler, void *ctx) {
|
||||
handler(ctx, MidiMessage{data, length, type});
|
||||
}
|
||||
void emit_current_(MidiMessageHandler handler, void *ctx);
|
||||
void take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx);
|
||||
void end_sysex_(bool complete, MidiMessageHandler handler, void *ctx);
|
||||
void sysex_push_(uint8_t byte);
|
||||
|
||||
// SysEx reassembly across packets.
|
||||
uint8_t *const sysex_buffer_;
|
||||
const uint16_t sysex_capacity_;
|
||||
uint16_t sysex_length_{0};
|
||||
bool sysex_active_{false};
|
||||
bool sysex_overflow_{false};
|
||||
|
||||
// Last channel-voice status byte, sticky across bytes and packets until a new
|
||||
// status byte or a system-common message clears it (MIDI running status).
|
||||
uint8_t running_status_{0};
|
||||
|
||||
// Message being assembled: status byte plus up to two data bytes.
|
||||
uint8_t current_message_[3]{};
|
||||
uint8_t current_length_{0};
|
||||
uint8_t current_expected_{0};
|
||||
};
|
||||
|
||||
// A BLE-MIDI packet starts with a header byte and a timestamp byte, both with bit 7 set. The remaining
|
||||
// timestamp bits serve jitter reconstruction only and are left at zero when transmitting.
|
||||
static constexpr uint8_t PACKET_HEADER = 0x80;
|
||||
static constexpr uint8_t PACKET_TIMESTAMP = 0x80;
|
||||
static constexpr uint8_t STATUS_SYSEX_START = 0xF0;
|
||||
static constexpr uint8_t STATUS_SYSEX_END = 0xF7;
|
||||
|
||||
/// Frame a SysEx payload for one BLE-MIDI packet: 0xF0 and 0xF7 are added when the payload lacks them, with
|
||||
/// the timestamp byte the specification requires before 0xF7. Returns the bytes written to `out`, or 0 when
|
||||
/// the payload is empty, holds a byte with bit 7 set, or does not fit `out_size` once framed.
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size);
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -1,12 +1,14 @@
|
||||
"""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 the neutral ble_client through gatt_client_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
|
||||
@@ -23,7 +25,8 @@ from esphome.types import ConfigType
|
||||
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."""
|
||||
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:
|
||||
@@ -63,6 +66,8 @@ DOMAIN = "bluetooth_connection"
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
# GATT connection slots claimed this run, for the platform cap check.
|
||||
slot_consumers: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
@@ -119,6 +124,10 @@ class _PlatformBackend:
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
|
||||
# Selects the backend's alias-ladder arm (order-independent arms).
|
||||
define: str
|
||||
# The backend's on-demand materializer gate, when it has one.
|
||||
materializer_define: str | None = None
|
||||
|
||||
|
||||
# The single registry of platforms with a GATT client backend; a platform
|
||||
@@ -126,11 +135,20 @@ class _PlatformBackend:
|
||||
# platform's arm.
|
||||
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
|
||||
PLATFORM_ESP32: _PlatformBackend(
|
||||
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
|
||||
BluedroidGattClient,
|
||||
_esp32_schema_fragment,
|
||||
_esp32_register,
|
||||
"USE_BLE_GATT_BACKEND_BLUEDROID",
|
||||
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
|
||||
),
|
||||
PLATFORM_RP2: _PlatformBackend(
|
||||
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
|
||||
),
|
||||
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
|
||||
@@ -165,21 +183,97 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
|
||||
)
|
||||
|
||||
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
rp2040_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
# Platforms whose BLE stack owns its own connection budget: the claim is
|
||||
# charged there and that stack's final validation is the one place an
|
||||
# overcommit is reported (never two messages for one misconfiguration). One
|
||||
# mapping drives both, so a platform can never be skipped without a charge.
|
||||
_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = {
|
||||
PLATFORM_ESP32: _charge_esp32_budget,
|
||||
PLATFORM_RP2: _charge_rp2_budget,
|
||||
}
|
||||
|
||||
|
||||
def consume_gatt_slot(
|
||||
consumer: str, count: int = 1
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Validator claiming GATT connection slots - the one spelling for every
|
||||
claimant. The neutral ledger covers backend platforms without a stack
|
||||
budget (the cap check in FINAL_VALIDATE_SCHEMA)."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_get_data().slot_consumers.extend([consumer] * count)
|
||||
if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None:
|
||||
charge(count, consumer, config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _validate_slot_totals(config: ConfigType) -> ConfigType:
|
||||
# Skipped in testing mode so grouped component builds can co-exist
|
||||
# (mirrors esp32_ble.validate_connection_slots).
|
||||
if CORE.testing_mode:
|
||||
return config
|
||||
if CORE.target_platform in _STACK_BUDGETS:
|
||||
return config
|
||||
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
|
||||
# Any backend platform without a stack budget must carry a cap here
|
||||
# or fail loudly, never fail open.
|
||||
if CORE.target_platform in _PLATFORM_BACKENDS:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} has a GATT backend but no slot cap "
|
||||
"in HUB_MAX_CONNECTIONS"
|
||||
)
|
||||
return config
|
||||
claimed = _get_data().slot_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
|
||||
|
||||
|
||||
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.
|
||||
(the consume_gatt_slot validators), not here.
|
||||
|
||||
service_table is honored by the Bluedroid backend only: forward
|
||||
scaffolding for the first esp32 direct consumer, load-bearing on no
|
||||
current build (rp2 ignores the define and always materializes - its
|
||||
proxy hub streams through get_service_table(), so it must keep the
|
||||
materializer regardless of the flag).
|
||||
"""
|
||||
from esphome.components import ble_device_base
|
||||
|
||||
entry = _backend_entry()
|
||||
ble_device_base.request_gatt_client()
|
||||
cg.add_define(entry.define)
|
||||
if service_table and entry.materializer_define is not None:
|
||||
cg.add_define(entry.materializer_define)
|
||||
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)
|
||||
await _backend_entry().register(backend, config)
|
||||
await entry.register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
@@ -187,6 +281,7 @@ async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
# 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]),
|
||||
"gatt_service_table_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.
|
||||
|
||||
@@ -46,7 +46,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
|
||||
@@ -65,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
|
||||
@@ -49,15 +49,16 @@ static constexpr conn_err_t CONN_OK = 0;
|
||||
// GATT contract so backend and wrapper cannot drift.
|
||||
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
|
||||
// What the platform's connection backend supports beyond GATT operations;
|
||||
// the proxy derives its feature flags and legacy version from these.
|
||||
#if defined(USE_ESP32)
|
||||
// What the build's connection backend supports beyond GATT operations; the
|
||||
// proxy derives its feature flags and legacy version from these. Keyed on
|
||||
// the backend define, never the platform, so a second backend on one
|
||||
// platform carries its own facts.
|
||||
#if defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
|
||||
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
// The rp2 BTstack backend pairs (just works + bonding); it has no service
|
||||
// cache to clear. Keyed on the backend, not the generic client define, so a
|
||||
// future backend without pairing keeps the stub arm below.
|
||||
// cache to clear.
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#else
|
||||
@@ -65,13 +66,14 @@ static constexpr bool SUPPORTS_PAIRING = false;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#endif
|
||||
|
||||
// Address-scoped (not connection-scoped) maintenance requests.
|
||||
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
// Address-scoped (not connection-scoped) maintenance requests; keyed on the
|
||||
// stack (the calls need no backend instance).
|
||||
#if (defined(USE_ESP32_BLE) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t unpair_device(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
#endif
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t clear_gatt_cache(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
|
||||
@@ -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_CONNECTIONS
|
||||
#include "bluetooth_connection.h"
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -345,6 +346,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
this->table_.free();
|
||||
#endif
|
||||
// Always set: terminates any in-flight stream on every cache config.
|
||||
this->services_released_ = true;
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
@@ -357,6 +361,24 @@ void BluedroidGattClient::release_services() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
|
||||
// Lifetime: every teardown path (CLOSE_EVT, the safety timeout, stack-down,
|
||||
// passive DISCONNECT) routes through release_services(), so a materialized
|
||||
// table cannot outlive its link.
|
||||
if (this->table_.empty() &&
|
||||
(this->services_released_ || this->service_total_ == 0 ||
|
||||
!this->table_.build(this->gattc_if_, this->conn_id_, this->service_total_, this->connection_index_))) {
|
||||
// Released / no services / failed build all collapse to empty; the
|
||||
// build failures warned above, log the quiet two.
|
||||
ESP_LOGD(TAG, "[%d] No service table (released=%d, services=%u)", this->connection_index_, this->services_released_,
|
||||
this->service_total_);
|
||||
return {};
|
||||
}
|
||||
return this->table_.view();
|
||||
}
|
||||
#endif // USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
|
||||
@@ -403,6 +425,11 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
|
||||
// ---- service streaming ----
|
||||
|
||||
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
// Re-discovery moves the counts the table view derives offsets from; free
|
||||
// the stale table.
|
||||
this->table_.free();
|
||||
#endif
|
||||
// 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) {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
#include "gatt_service_table_bluedroid.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"
|
||||
@@ -76,11 +79,16 @@ 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.
|
||||
// On-demand table for direct consumers; the proxy streams instead, so the
|
||||
// materializer compiles only under USE_BLUEDROID_GATT_SERVICE_TABLE (emitted by
|
||||
// direct-consumer codegen, never by the proxy).
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
#else
|
||||
// A direct consumer reaching this stub misconfigured its codegen
|
||||
// (service_table=False): the empty table reads as a service-less peer.
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
@@ -110,6 +118,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
|
||||
// Group 1: pointers / composed objects
|
||||
ble_device_base::GattClientListener *listener_{nullptr};
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
BluedroidServiceTable table_;
|
||||
#endif
|
||||
// Group 2: 4-byte types
|
||||
uint32_t disconnecting_started_{0};
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
|
||||
#if defined(USE_RP2040_BLE)
|
||||
// Arms are keyed on codegen-emitted per-backend defines (_PLATFORM_BACKENDS
|
||||
// in __init__.py), so they are order-independent.
|
||||
#if defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
#include "bluetooth_connection_rp2.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
|
||||
#elif defined(USE_ESP32_BLE)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
#include "bluetooth_connection_bluedroid.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
|
||||
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "gatt_service_table_bluedroid.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "bluetooth_connection");
|
||||
|
||||
// A stack that never reports end-of-range would otherwise walk forever.
|
||||
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
|
||||
|
||||
// Shared enumeration for both 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.
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
|
||||
for (uint16_t s = 0; s < this->service_total_; s++) {
|
||||
esp_gattc_service_elem_t svc;
|
||||
uint16_t svc_count = 1;
|
||||
auto svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s);
|
||||
if (svc_status != ESP_GATT_OK || svc_count == 0) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_service", svc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_service(s, svc)) {
|
||||
return false;
|
||||
}
|
||||
uint16_t svc_chars = 0;
|
||||
auto count_status = 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);
|
||||
if (count_status != ESP_GATT_OK) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_attr_count", count_status);
|
||||
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_OK || char_count == 0) {
|
||||
// An early terminator contradicts svc_chars from the same cache;
|
||||
// never build a silently truncated table.
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_char", status);
|
||||
return false;
|
||||
}
|
||||
if (!on_char(svc, chr)) {
|
||||
return false;
|
||||
}
|
||||
for (uint16_t d = 0;; d++) {
|
||||
if (d == MAX_DESCRIPTORS_PER_CHARACTERISTIC) {
|
||||
// A stack that never reports end-of-range; fail like every other
|
||||
// inconsistency instead of truncating the table silently.
|
||||
ESP_LOGW(TAG, "[%d] Descriptor walk exceeded %u entries", this->log_index_,
|
||||
MAX_DESCRIPTORS_PER_CHARACTERISTIC);
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_descr", desc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_desc(chr, desc)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
|
||||
uint16_t primary = 0;
|
||||
uint16_t secondary = 0;
|
||||
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
|
||||
ESP_GATT_OK ||
|
||||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
|
||||
ESP_GATT_OK) {
|
||||
// A failed count must not read as an authoritative empty database.
|
||||
return false;
|
||||
}
|
||||
*total = primary + secondary;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
|
||||
this->free();
|
||||
this->gattc_if_ = gattc_if;
|
||||
this->conn_id_ = conn_id;
|
||||
this->service_total_ = service_total;
|
||||
this->log_index_ = log_index;
|
||||
|
||||
// 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_([](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) {
|
||||
ESP_LOGW(TAG, "[%d] Service table walk failed during count", this->log_index_);
|
||||
this->free();
|
||||
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.
|
||||
static_assert(alignof(ble_device_base::GattService) >= alignof(ble_device_base::GattCharacteristic) &&
|
||||
alignof(ble_device_base::GattCharacteristic) >= alignof(ble_device_base::GattDescriptor));
|
||||
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->storage_ = allocator.allocate(total_bytes);
|
||||
if (this->storage_ == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->log_index_,
|
||||
static_cast<unsigned>(total_bytes));
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->storage_);
|
||||
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes);
|
||||
auto *descriptors = reinterpret_cast<ble_device_base::GattDescriptor *>(this->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_(
|
||||
[&](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) {
|
||||
// Walk error or the database changed between passes; better an empty
|
||||
// table than a corrupt one.
|
||||
ESP_LOGW(TAG, "[%d] Service table walk mismatch, discarding", this->log_index_);
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
this->char_total_ = char_total;
|
||||
this->desc_total_ = desc_total;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluedroidServiceTable::log_walk_warning_(const char *operation, int code) {
|
||||
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->log_index_, operation, code);
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -0,0 +1,82 @@
|
||||
// Owning two-pass materializer of one Bluedroid GATT database snapshot into
|
||||
// the neutral GattServiceTable layout, shared by the BluedroidGattClient
|
||||
// backend and ble_client's esp32 engine.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
class BluedroidServiceTable {
|
||||
public:
|
||||
~BluedroidServiceTable() { this->free(); }
|
||||
// Owns storage_; a copy would double-free. The default constructor is user
|
||||
// provided, not "= default", so value-initializing the enclosing backend
|
||||
// cannot zero-fill .bss that is already zero.
|
||||
BluedroidServiceTable() {}
|
||||
BluedroidServiceTable(const BluedroidServiceTable &) = delete;
|
||||
BluedroidServiceTable &operator=(const BluedroidServiceTable &) = delete;
|
||||
|
||||
/// The service count build() requires: the stack's PRIMARY+SECONDARY
|
||||
/// attribute totals, never the SEARCH_RES event count.
|
||||
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
|
||||
|
||||
/// Two-pass build from the stack's cached database (service_total from
|
||||
/// count_services()). log_index labels warnings. Frees any previous table
|
||||
/// first; on failure the table is left empty.
|
||||
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
|
||||
|
||||
// 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 view() const {
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = this->char_total_ * sizeof(ble_device_base::GattCharacteristic);
|
||||
return {reinterpret_cast<const ble_device_base::GattService *>(this->storage_),
|
||||
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes),
|
||||
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes),
|
||||
this->service_total_,
|
||||
this->char_total_,
|
||||
this->desc_total_};
|
||||
}
|
||||
|
||||
// Always resets the counts: a failed build must never leave a non-zero
|
||||
// service_total_ behind a null table.
|
||||
void free() {
|
||||
if (this->storage_ != nullptr) {
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
allocator.deallocate(this->storage_, 0);
|
||||
this->storage_ = nullptr;
|
||||
}
|
||||
this->service_total_ = 0;
|
||||
this->char_total_ = 0;
|
||||
this->desc_total_ = 0;
|
||||
}
|
||||
|
||||
bool empty() const { return this->storage_ == nullptr; }
|
||||
|
||||
private:
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
|
||||
void log_walk_warning_(const char *operation, int code);
|
||||
|
||||
uint8_t *storage_{nullptr};
|
||||
uint16_t service_total_{0};
|
||||
uint16_t char_total_{0};
|
||||
uint16_t desc_total_{0};
|
||||
// Walk context, set by build().
|
||||
uint16_t conn_id_{0};
|
||||
esp_gatt_if_t gattc_if_{}; // uint8_t width
|
||||
uint8_t log_index_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -94,10 +94,20 @@ def _esp32_config_schema() -> cv.All:
|
||||
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
|
||||
|
||||
def validate_connections(config: ConfigType) -> ConfigType:
|
||||
if CONF_CONNECTIONS in config or not config[CONF_ACTIVE]:
|
||||
if CONF_CONNECTIONS in config:
|
||||
# Explicit entries claim slots like the generated ones; dev
|
||||
# historically skipped this, letting an explicit-connections
|
||||
# config evade the controller budget.
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", len(config[CONF_CONNECTIONS])
|
||||
)(config)
|
||||
return config
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
|
||||
@@ -149,14 +159,14 @@ def _rp2_config_schema() -> cv.All:
|
||||
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", connection_slots)(
|
||||
config
|
||||
)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
@@ -206,7 +216,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
# sends those requests and their handlers and encoders are dead.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
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))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -46,16 +47,24 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
|
||||
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
UNIT_KELVIN,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
TemperatureUnitResolver,
|
||||
convert_visual_temperatures,
|
||||
declare_fixed_temperature_unit,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
setup_entity_temperature_unit,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType, SafeExpType
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -138,30 +147,33 @@ CONF_MIN_HUMIDITY = "min_humidity"
|
||||
CONF_MAX_HUMIDITY = "max_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
|
||||
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
||||
|
||||
|
||||
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
|
||||
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Remove before 2027.5.0: external components imported it before temperature_with_unit replaced it
|
||||
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
||||
|
||||
|
||||
def visual_temperature_step(value: Any) -> ConfigType:
|
||||
# Allow defining target/current temperature steps separately
|
||||
if isinstance(value, dict):
|
||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
|
||||
|
||||
# Otherwise, use the single value for both properties
|
||||
value = visual_temperature(value)
|
||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(
|
||||
{
|
||||
CONF_TARGET_TEMPERATURE: value,
|
||||
CONF_CURRENT_TEMPERATURE: value,
|
||||
}
|
||||
)
|
||||
value = cv.temperature_with_unit(value)
|
||||
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
|
||||
|
||||
|
||||
_visual_temperatures_in_entity_unit = partial(
|
||||
convert_visual_temperatures,
|
||||
delta_keys=(CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE),
|
||||
delta_section=CONF_TEMPERATURE_STEP,
|
||||
)
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA = (
|
||||
@@ -172,8 +184,8 @@ _CLIMATE_SCHEMA = (
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
|
||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
|
||||
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
|
||||
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
|
||||
@@ -250,6 +262,24 @@ _CLIMATE_SCHEMA = (
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
|
||||
_CLIMATE_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
|
||||
|
||||
|
||||
def _final_validate(config: list[ConfigType]) -> None:
|
||||
# Runs once for the whole domain; converts what waited for a platform's unit resolver
|
||||
full_config = fv.full_config.get()
|
||||
for entry in config:
|
||||
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _unit_mqtt_can_report(value: str) -> str:
|
||||
# MQTT discovery only knows °C and °F
|
||||
if value == UNIT_KELVIN and "mqtt" in CORE.loaded_integrations:
|
||||
raise cv.Invalid("MQTT reports climate temperatures in °C or °F only")
|
||||
return value
|
||||
|
||||
|
||||
def climate_schema(
|
||||
@@ -257,10 +287,26 @@ def climate_schema(
|
||||
*,
|
||||
entity_category: str = cv.UNDEFINED,
|
||||
icon: str = cv.UNDEFINED,
|
||||
temperature_unit: bool = False,
|
||||
fixed_temperature_unit: str | TemperatureUnitResolver | None = None,
|
||||
) -> cv.Schema:
|
||||
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
|
||||
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
|
||||
``fixed_temperature_unit`` is for a device that reports in that unit only, set by its traits,
|
||||
or a resolver ``(entity config, full config) -> unit`` when its own options pick the unit."""
|
||||
if temperature_unit and fixed_temperature_unit is not None:
|
||||
raise ValueError(
|
||||
"a platform takes unit_of_measurement or has a fixed unit, not both"
|
||||
)
|
||||
if fixed_temperature_unit is not None:
|
||||
declare_fixed_temperature_unit(class_, fixed_temperature_unit)
|
||||
schema = {
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
}
|
||||
if temperature_unit:
|
||||
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.All(
|
||||
cv.temperature_unit, _unit_mqtt_can_report
|
||||
)
|
||||
|
||||
for key, default, validator in [
|
||||
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
|
||||
@@ -305,6 +351,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
|
||||
if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None:
|
||||
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
|
||||
cg.add(var.set_visual_max_humidity_override(max_humidity))
|
||||
setup_entity_temperature_unit(var, config)
|
||||
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
@@ -479,9 +526,9 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(Climate),
|
||||
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||
@@ -497,17 +544,23 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
TARGET_TEMPERATURE_APPLY_FIELDS = tuple(
|
||||
automation.ApplyField(key, setter, cg.float_, temperature_in_entity_unit)
|
||||
for key, setter in (
|
||||
(CONF_TARGET_TEMPERATURE, "set_target_temperature"),
|
||||
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low"),
|
||||
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high"),
|
||||
)
|
||||
)
|
||||
TARGET_TEMPERATURE_KEYS = tuple(
|
||||
field.conf_key for field in TARGET_TEMPERATURE_APPLY_FIELDS
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"climate.control",
|
||||
CLIMATE_CONTROL_ACTION_SCHEMA,
|
||||
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
|
||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
|
||||
),
|
||||
*TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
||||
automation.ApplyField(
|
||||
|
||||
@@ -77,6 +77,10 @@ class ClimateTraits {
|
||||
friend class Climate; // Climate wires its custom mode lists into traits
|
||||
|
||||
public:
|
||||
// The visual range a platform starts from, in °C
|
||||
static constexpr float DEFAULT_VISUAL_MIN_TEMPERATURE = 10.0f;
|
||||
static constexpr float DEFAULT_VISUAL_MAX_TEMPERATURE = 30.0f;
|
||||
|
||||
// Out of line on purpose: an inlined default init makes GCC copy the defaults from a .rodata
|
||||
// template per traits() override, and .rodata is RAM on ESP8266.
|
||||
ClimateTraits();
|
||||
@@ -193,8 +197,8 @@ class ClimateTraits {
|
||||
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
|
||||
|
||||
uint32_t feature_flags_{0};
|
||||
float visual_min_temperature_{10};
|
||||
float visual_max_temperature_{30};
|
||||
float visual_min_temperature_{DEFAULT_VISUAL_MIN_TEMPERATURE};
|
||||
float visual_max_temperature_{DEFAULT_VISUAL_MAX_TEMPERATURE};
|
||||
float visual_target_temperature_step_{0.1};
|
||||
float visual_current_temperature_step_{0.1};
|
||||
float visual_min_humidity_{30};
|
||||
|
||||
@@ -33,9 +33,11 @@ ClimateIR = climate_ir_ns.class_(
|
||||
|
||||
def climate_ir_schema(
|
||||
class_: MockObjClass,
|
||||
*,
|
||||
fixed_temperature_unit: str | None = None,
|
||||
) -> cv.Schema:
|
||||
return (
|
||||
climate.climate_schema(class_)
|
||||
climate.climate_schema(class_, fixed_temperature_unit=fixed_temperature_unit)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
|
||||
@@ -52,8 +54,12 @@ def climate_ir_schema(
|
||||
|
||||
def climate_ir_with_receiver_schema(
|
||||
class_: MockObjClass,
|
||||
*,
|
||||
fixed_temperature_unit: str | None = None,
|
||||
) -> cv.Schema:
|
||||
return climate_ir_schema(class_).extend(
|
||||
return climate_ir_schema(
|
||||
class_, fixed_temperature_unit=fixed_temperature_unit
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
|
||||
remote_base.RemoteReceiverBase
|
||||
|
||||
@@ -13,19 +13,10 @@ from esphome.const import (
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import copy_ns
|
||||
|
||||
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(CopySensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
||||
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "copy_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "copy.sensor");
|
||||
|
||||
void CopySensor::setup() {
|
||||
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
||||
if (source_->has_state())
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
class CopySensor final : public sensor::Sensor, public Component {
|
||||
public:
|
||||
void set_source(sensor::Sensor *source) { source_ = source; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
sensor::Sensor *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import copy_ns
|
||||
|
||||
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor.text_sensor_schema(CopyTextSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
|
||||
{
|
||||
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
||||
}
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await text_sensor.new_text_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
||||
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "copy_text_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
|
||||
|
||||
void CopyTextSensor::setup() {
|
||||
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
|
||||
if (source_->has_state())
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
namespace esphome::copy {
|
||||
|
||||
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
|
||||
public:
|
||||
void set_source(text_sensor::TextSensor *source) { source_ = source; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
text_sensor::TextSensor *source_;
|
||||
};
|
||||
|
||||
} // namespace esphome::copy
|
||||
@@ -5,6 +5,7 @@ from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@epicRE"]
|
||||
AUTO_LOAD = ["number"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "delta_wallbox"
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -54,6 +55,8 @@ static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0
|
||||
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
|
||||
|
||||
static constexpr uint16_t REG_MAX_CURRENT = 0x0112;
|
||||
|
||||
// Indexed by DeltaState
|
||||
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
|
||||
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
|
||||
@@ -103,6 +106,13 @@ static bool verify_crc(const uint8_t *data, size_t len) {
|
||||
return calculated == received;
|
||||
}
|
||||
|
||||
static uint64_t read_be(const uint8_t *p, size_t n) {
|
||||
uint64_t value = 0;
|
||||
for (size_t i = 0; i < n; i++)
|
||||
value = (value << 8) | p[i];
|
||||
return value;
|
||||
}
|
||||
|
||||
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
|
||||
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
|
||||
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
||||
@@ -448,15 +458,16 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
uint8_t byte_count = frame[2];
|
||||
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
||||
// Parsing 0x011B charging info (9 registers)
|
||||
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
|
||||
uint8_t minutes = frame[15];
|
||||
uint8_t hours = frame[16];
|
||||
uint64_t power_raw = std::min<uint64_t>(read_be(&frame[3], 7), 999900);
|
||||
uint64_t energy_raw = read_be(&frame[10], 4);
|
||||
uint8_t hours = std::min<uint8_t>(frame[14], 99);
|
||||
uint8_t minutes = std::min<uint8_t>(frame[15], 59);
|
||||
uint8_t seconds = std::min<uint8_t>(frame[16], 59);
|
||||
uint8_t state_byte = frame[17];
|
||||
|
||||
float power_kw = power_raw / 1000.0f;
|
||||
float energy_kwh = energy_raw / 1000.0f;
|
||||
float charging_minutes = hours * 60 + minutes;
|
||||
float charging_minutes = hours * 60.0f + minutes + seconds / 60.0f;
|
||||
|
||||
if (this->power_sensor_ != nullptr)
|
||||
this->power_sensor_->publish_state(power_kw);
|
||||
@@ -471,11 +482,13 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
||||
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
||||
// Parsing 0x0112 max output setting (2 registers)
|
||||
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
float amps = current_raw / 10.0f;
|
||||
if (this->max_current_sensor_ != nullptr) {
|
||||
this->max_current_sensor_->publish_state(amps);
|
||||
}
|
||||
// frame[4] is the configured max (set by DIP switch in device)
|
||||
// frame[5] the active limit
|
||||
this->max_current_limit_ = frame[4];
|
||||
if (this->max_current_sensor_ != nullptr)
|
||||
this->max_current_sensor_->publish_state(frame[5]);
|
||||
if (this->max_current_number_ != nullptr)
|
||||
this->max_current_number_->publish_state(frame[5]);
|
||||
|
||||
// Poll the error code register every cycle
|
||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||
@@ -488,8 +501,15 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
this->error_code_text_sensor_->publish_state(hex_str);
|
||||
}
|
||||
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
const uint16_t register_addr = (static_cast<uint16_t>(frame[2]) << 8) | frame[3];
|
||||
if (register_addr == REG_MAX_CURRENT) {
|
||||
ESP_LOGI(TAG, "Wallbox max current command acknowledged: %u A", frame[5]);
|
||||
if (this->max_current_number_ != nullptr)
|
||||
this->max_current_number_->publish_state(frame[5]);
|
||||
} else {
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -503,6 +523,7 @@ void DeltaWallbox::reset_state_() {
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
||||
this->max_current_limit_ = 0; // refuse writes until the next 0x0112 read
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning();
|
||||
@@ -533,6 +554,35 @@ void DeltaWallbox::set_charging_state(bool state) {
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::set_max_current(float current) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGW(TAG, "Cannot set maximum current while wallbox is not fully connected.");
|
||||
return;
|
||||
}
|
||||
const uint8_t upper = this->max_current_limit_;
|
||||
if (upper == 0) {
|
||||
ESP_LOGW(TAG, "Wallbox current limit not read yet; refusing to set %.0f A", current);
|
||||
return;
|
||||
}
|
||||
const int32_t rounded = std::lround(current);
|
||||
if (rounded < 1) {
|
||||
ESP_LOGW(TAG, "Rejecting maximum current %d A", static_cast<int>(rounded));
|
||||
return;
|
||||
}
|
||||
const uint8_t requested = static_cast<uint8_t>(std::min<int32_t>(rounded, upper));
|
||||
if (rounded > upper) {
|
||||
ESP_LOGW(TAG, "Requested %d A exceeds wallbox limit %u A; using %u A", static_cast<int>(rounded), upper, upper);
|
||||
}
|
||||
ESP_LOGI(TAG, "Setting wallbox maximum current to %u A", requested);
|
||||
const std::array<uint8_t, 6> body = {0x01,
|
||||
FC_WRITE_SINGLE_REGISTER,
|
||||
static_cast<uint8_t>(REG_MAX_CURRENT >> 8),
|
||||
static_cast<uint8_t>(REG_MAX_CURRENT & 0xFF),
|
||||
0x00,
|
||||
requested};
|
||||
this->send_frame_(body.data(), body.size());
|
||||
}
|
||||
|
||||
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
||||
|
||||
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <array>
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/number/number.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -56,12 +57,14 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
||||
SUB_SENSOR(charging_time)
|
||||
SUB_SENSOR(charger_state)
|
||||
SUB_SENSOR(max_current)
|
||||
SUB_NUMBER(max_current) // settable max-current number entity
|
||||
SUB_TEXT_SENSOR(serial_number)
|
||||
SUB_TEXT_SENSOR(fw_version)
|
||||
SUB_TEXT_SENSOR(bt_fw_version)
|
||||
SUB_TEXT_SENSOR(model_name)
|
||||
SUB_TEXT_SENSOR(error_code)
|
||||
void set_charging_state(bool state);
|
||||
void set_max_current(float current);
|
||||
void start_charging();
|
||||
void stop_charging();
|
||||
|
||||
@@ -95,6 +98,7 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
||||
uint16_t tx_char_handle_{0};
|
||||
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
||||
uint8_t pairing_attempts_{0};
|
||||
uint8_t max_current_limit_{0};
|
||||
bool has_pin_{false};
|
||||
bool pairing_in_progress_{false};
|
||||
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MAX_CURRENT, ENTITY_CATEGORY_CONFIG, UNIT_AMPERE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_MAX_CURRENT): number.number_schema(
|
||||
number.ApplyNumber,
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon="mdi:flash",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
numbers = number.sub_numbers(config, parent=config[CONF_DELTA_WALLBOX_ID])
|
||||
await numbers(
|
||||
CONF_MAX_CURRENT,
|
||||
hub.set_max_current_number,
|
||||
min_value=6,
|
||||
max_value=32,
|
||||
step=1,
|
||||
apply="set_max_current(value)",
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core
|
||||
from esphome import automation, core, pins
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import (
|
||||
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_FROM,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
|
||||
}
|
||||
|
||||
|
||||
class DisplayModel(Protocol):
|
||||
"""The parts of a display model used by enable_pin_schema()."""
|
||||
|
||||
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
|
||||
|
||||
|
||||
def enable_pin_schema(
|
||||
model: DisplayModel, config: ConfigType
|
||||
) -> dict[cv.Optional, Any]:
|
||||
"""
|
||||
Get the schema entry for a display's enable pins.
|
||||
Pins given in the user config are used as they are. Otherwise the model's default enable
|
||||
pins are used, except any that is also used elsewhere in the config, so a user can take
|
||||
control of that pin, e.g. with a switch.
|
||||
:param config: The display config as given by the user
|
||||
"""
|
||||
schema = cv.ensure_list(pins.gpio_output_pin_schema)
|
||||
default = model.get_default(CONF_ENABLE_PIN, None)
|
||||
if default is None or CONF_ENABLE_PIN in config:
|
||||
return {cv.Optional(CONF_ENABLE_PIN): schema}
|
||||
return {
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=default
|
||||
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
|
||||
}
|
||||
|
||||
|
||||
def validate_rotation(value):
|
||||
value = cv.string(value)
|
||||
value = value.removesuffix("°")
|
||||
|
||||
@@ -146,7 +146,7 @@ def model_schema(config):
|
||||
cv.GenerateID(): cv.declare_id(class_name),
|
||||
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
|
||||
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
|
||||
map_sequence
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Self
|
||||
|
||||
from esphome.components.mipi import check_model_requirements
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -128,24 +128,5 @@ class EpaperModel:
|
||||
return model
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
"""Check that the components this model needs are configured, see check_model_requirements()."""
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR, UNIT_FAHRENHEIT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -13,7 +13,10 @@ FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
|
||||
Model = friedrich_ns.enum("Model", is_class=True)
|
||||
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
|
||||
# The unit reports in Fahrenheit (traits() in friedrich.cpp), so visual temperatures are kept in it
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||
FriedrichClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
|
||||
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
|
||||
|
||||
@@ -15,34 +15,25 @@ ESPHOME_LOG_TAG(TAG, "http_request.ota");
|
||||
|
||||
void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); };
|
||||
|
||||
OtaHttpRequestComponent::Request &OtaHttpRequestComponent::get_request_() {
|
||||
if (this->request_ == nullptr)
|
||||
this->request_ = make_unique<Request>();
|
||||
return *this->request_;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_md5_url(const std::string &url) {
|
||||
if (!this->validate_url_(url)) {
|
||||
if (this->request_ != nullptr)
|
||||
this->request_->md5_url.clear(); // URL was not valid; prevent flashing until it is
|
||||
this->md5_url_.clear(); // URL was not valid; prevent flashing until it is
|
||||
return;
|
||||
}
|
||||
Request &request = this->get_request_();
|
||||
request.md5_url = url;
|
||||
request.md5_expected.clear(); // to be retrieved later
|
||||
this->md5_url_ = url;
|
||||
this->md5_expected_.clear(); // to be retrieved later
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_url(const std::string &url) {
|
||||
if (!this->validate_url_(url)) {
|
||||
if (this->request_ != nullptr)
|
||||
this->request_->url.clear(); // URL was not valid; prevent flashing until it is
|
||||
this->url_.clear(); // URL was not valid; prevent flashing until it is
|
||||
return;
|
||||
}
|
||||
this->get_request_().url = url;
|
||||
this->url_ = url;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::flash() {
|
||||
if (this->request_ == nullptr || this->request_->url.empty()) {
|
||||
if (this->url_.empty()) {
|
||||
ESP_LOGE(TAG, "URL not set; cannot start update");
|
||||
return;
|
||||
}
|
||||
@@ -67,8 +58,8 @@ void OtaHttpRequestComponent::flash() {
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status);
|
||||
#endif
|
||||
this->request_->md5_computed.clear(); // will be reset at next attempt
|
||||
this->request_->md5_expected.clear(); // will be reset at next attempt
|
||||
this->md5_computed_.clear(); // will be reset at next attempt
|
||||
this->md5_expected_.clear(); // will be reset at next attempt
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -90,18 +81,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
md5::MD5Digest md5_receive;
|
||||
char md5_receive_str[33];
|
||||
|
||||
if (this->request_->md5_expected.empty() && !this->http_get_md5_()) {
|
||||
if (this->md5_expected_.empty() && !this->http_get_md5_()) {
|
||||
return OTA_MD5_INVALID;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "MD5 expected: %s", this->request_->md5_expected.c_str());
|
||||
ESP_LOGD(TAG, "MD5 expected: %s", this->md5_expected_.c_str());
|
||||
|
||||
auto url_with_auth = this->get_url_with_auth_(this->request_->url);
|
||||
auto url_with_auth = this->get_url_with_auth_(this->url_);
|
||||
if (url_with_auth.empty()) {
|
||||
return OTA_BAD_URL;
|
||||
}
|
||||
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->request_->url.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->url_.c_str());
|
||||
|
||||
auto container = this->parent_->get(url_with_auth);
|
||||
|
||||
@@ -188,9 +179,9 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
// verify MD5 is as expected and act accordingly
|
||||
md5_receive.calculate();
|
||||
md5_receive.get_hex(md5_receive_str);
|
||||
this->request_->md5_computed = md5_receive_str;
|
||||
if (strncmp(this->request_->md5_computed.c_str(), this->request_->md5_expected.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->request_->md5_computed.c_str());
|
||||
this->md5_computed_ = md5_receive_str;
|
||||
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
|
||||
} else {
|
||||
@@ -232,15 +223,11 @@ static std::string url_encode(const std::string &str) {
|
||||
return result;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_password(const std::string &password) {
|
||||
this->get_request_().password = url_encode(password);
|
||||
}
|
||||
void OtaHttpRequestComponent::set_username(const std::string &username) {
|
||||
this->get_request_().username = url_encode(username);
|
||||
}
|
||||
void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); }
|
||||
void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); }
|
||||
|
||||
std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) {
|
||||
if (this->request_->username.empty() || this->request_->password.empty()) {
|
||||
if (this->username_.empty() || this->password_.empty()) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -253,23 +240,23 @@ std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url)
|
||||
ESP_LOGD(TAG, "Using basic HTTP authentication");
|
||||
|
||||
start_char += 3; // skip '://' characters
|
||||
auto url_with_auth = url.substr(0, start_char) + this->request_->username + ":" + this->request_->password + "@" +
|
||||
url.substr(start_char);
|
||||
auto url_with_auth =
|
||||
url.substr(0, start_char) + this->username_ + ":" + this->password_ + "@" + url.substr(start_char);
|
||||
return url_with_auth;
|
||||
}
|
||||
|
||||
bool OtaHttpRequestComponent::http_get_md5_() {
|
||||
if (this->request_->md5_url.empty()) {
|
||||
if (this->md5_url_.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto url_with_auth = this->get_url_with_auth_(this->request_->md5_url);
|
||||
auto url_with_auth = this->get_url_with_auth_(this->md5_url_);
|
||||
if (url_with_auth.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->request_->md5_url.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->md5_url_.c_str());
|
||||
auto container = this->parent_->get(url_with_auth);
|
||||
if (container == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to connect to MD5 URL");
|
||||
@@ -286,8 +273,8 @@ bool OtaHttpRequestComponent::http_get_md5_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
this->request_->md5_expected.resize(MD5_SIZE);
|
||||
auto result = http_read_fully(container.get(), (uint8_t *) this->request_->md5_expected.data(), MD5_SIZE, MD5_SIZE,
|
||||
this->md5_expected_.resize(MD5_SIZE);
|
||||
auto result = http_read_fully(container.get(), (uint8_t *) this->md5_expected_.data(), MD5_SIZE, MD5_SIZE,
|
||||
this->parent_->get_timeout());
|
||||
container->end();
|
||||
|
||||
|
||||
@@ -27,36 +27,29 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
void set_md5_url(const std::string &md5_url);
|
||||
void set_md5(const std::string &md5) { this->get_request_().md5_expected = md5; }
|
||||
void set_md5(const std::string &md5) { this->md5_expected_ = md5; }
|
||||
void set_password(const std::string &password);
|
||||
void set_url(const std::string &url);
|
||||
void set_username(const std::string &username);
|
||||
|
||||
std::string md5_computed() { return this->request_ != nullptr ? this->request_->md5_computed : std::string(); }
|
||||
std::string md5_expected() { return this->request_ != nullptr ? this->request_->md5_expected : std::string(); }
|
||||
std::string md5_computed() { return this->md5_computed_; }
|
||||
std::string md5_expected() { return this->md5_expected_; }
|
||||
|
||||
void flash();
|
||||
|
||||
protected:
|
||||
/// Allocated by the first setter, which only runs before a flash, and never freed, so a flash never loses it.
|
||||
struct Request {
|
||||
std::string md5_computed;
|
||||
std::string md5_expected;
|
||||
std::string md5_url;
|
||||
std::string password;
|
||||
std::string username;
|
||||
std::string url;
|
||||
};
|
||||
Request &get_request_();
|
||||
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
|
||||
// Only reached from flash(), which returns early while request_ is null
|
||||
uint8_t do_ota_();
|
||||
std::string get_url_with_auth_(const std::string &url);
|
||||
bool http_get_md5_();
|
||||
bool validate_url_(const std::string &url);
|
||||
|
||||
std::unique_ptr<Request> request_;
|
||||
std::string md5_computed_{};
|
||||
std::string md5_expected_{};
|
||||
std::string md5_url_{};
|
||||
std::string password_{};
|
||||
std::string username_{};
|
||||
std::string url_{};
|
||||
int status_ = -1;
|
||||
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SENSOR
|
||||
from esphome.const import CONF_SENSOR, UNIT_FAHRENHEIT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -11,7 +11,10 @@ CODEOWNERS = ["@ipopov"]
|
||||
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
||||
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
|
||||
# The unit reports in Fahrenheit (traits() in islandaire.cpp), so visual temperatures are kept in it
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||
IslandaireClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
).extend(
|
||||
{
|
||||
# The unit has no auto (heat/cool) mode
|
||||
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
||||
|
||||
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv.Optional(CONF_UPDATE_MODE): update_mode,
|
||||
# One or more GPIOs driven high during setup to power on the panel
|
||||
# (e.g. board power-enable rails), before reset and init.
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
|
||||
): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
cv.Optional(CONF_RESET_DURATION): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
@@ -15,6 +17,8 @@ from esphome.core import CORE
|
||||
from esphome.cpp_helpers import gpio_pin_expression
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@max246"]
|
||||
DOMAIN = "lightwaverf"
|
||||
|
||||
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("1s"))
|
||||
|
||||
|
||||
def _drop_unused_name(config: ConfigType) -> ConfigType:
|
||||
# Remove before 2027.5.0
|
||||
if config.pop(CONF_NAME, None) is not None:
|
||||
_LOGGER.warning(
|
||||
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
|
||||
"Removed in 2027.5.0"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
|
||||
cv.Required(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Required(CONF_CODE): cv.All(
|
||||
[cv.Any(cv.hex_uint8_t)],
|
||||
cv.Length(min=10),
|
||||
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
|
||||
}
|
||||
)
|
||||
).add_extra(_drop_unused_name)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
|
||||
@@ -28,6 +28,7 @@ from esphome.const import (
|
||||
CONF_X,
|
||||
CONF_Y,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import FixedVector
|
||||
|
||||
from ..automation import action_to_code
|
||||
@@ -41,6 +42,7 @@ from ..defines import (
|
||||
CONF_RADIUS,
|
||||
CONF_SRC,
|
||||
CONF_START_ANGLE,
|
||||
LValidator,
|
||||
addr,
|
||||
literal,
|
||||
)
|
||||
@@ -57,7 +59,7 @@ from ..lv_validation import (
|
||||
)
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
||||
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
||||
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
|
||||
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .img import CONF_IMAGE
|
||||
|
||||
@@ -221,7 +223,6 @@ RECT_PROPS = {
|
||||
"radius",
|
||||
"bg_opa",
|
||||
"bg_color",
|
||||
"bg_grad",
|
||||
"border_color",
|
||||
"border_width",
|
||||
"border_opa",
|
||||
@@ -239,6 +240,30 @@ RECT_PROPS = {
|
||||
"shadow_opa",
|
||||
)
|
||||
}
|
||||
RECT_PROPS.update(
|
||||
bg_grad=LValidator(
|
||||
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
|
||||
)
|
||||
)
|
||||
RECT_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
|
||||
cv.Optional(CONF_OPA): cv.invalid(
|
||||
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
|
||||
),
|
||||
}
|
||||
POLYGON_PROPS = {
|
||||
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
|
||||
}
|
||||
POLYGON_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
|
||||
**{
|
||||
cv.Optional(prop): cv.invalid(
|
||||
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
|
||||
)
|
||||
for prop in (*RECT_PROPS, CONF_OPA)
|
||||
if prop not in POLYGON_PROPS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _draw_line(layer, dsc, points):
|
||||
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
**DRAW_OPA_SCHEMA,
|
||||
**DRAW_SCHEMA,
|
||||
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**RECT_SCHEMA,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
|
||||
LINE_PROPS = {
|
||||
"width": STYLE_PROPS["line_width"],
|
||||
"color": STYLE_PROPS["line_color"],
|
||||
"dash-width": STYLE_PROPS["line_dash_width"],
|
||||
"dash-gap": STYLE_PROPS["line_dash_gap"],
|
||||
"dash_width": STYLE_PROPS["line_dash_width"],
|
||||
"dash_gap": STYLE_PROPS["line_dash_gap"],
|
||||
"round_start": lv_bool,
|
||||
"round_end": lv_bool,
|
||||
}
|
||||
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
|
||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**POLYGON_SCHEMA,
|
||||
},
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
|
||||
_draw_line(layer, line_dsc, points)
|
||||
|
||||
return await draw_to_code(
|
||||
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from esphome.const import (
|
||||
CONF_WIDTH,
|
||||
CONF_X,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import nullptr
|
||||
|
||||
@@ -64,7 +65,7 @@ from ..lv_validation import (
|
||||
)
|
||||
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
|
||||
from ..schemas import STATE_SCHEMA
|
||||
from ..styles import LVStyle
|
||||
from ..styles import LVStyle, create_style
|
||||
from ..types import (
|
||||
LV_EVENT,
|
||||
LvCompound,
|
||||
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
|
||||
CONF_STRIDE = "stride"
|
||||
CONF_TICK_STYLE = "tick_style"
|
||||
|
||||
|
||||
def _arc_style_name(indicator_id: str) -> str:
|
||||
"""Name of the style variable generated for an arc indicator."""
|
||||
return f"meter_arc_{indicator_id}_style"
|
||||
|
||||
|
||||
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
|
||||
#
|
||||
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
|
||||
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
|
||||
get_warnings().add(
|
||||
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
|
||||
)
|
||||
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
|
||||
style_var = await create_style(_arc_style_name(iid.id), props)
|
||||
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
|
||||
lv.scale_section_set_style(
|
||||
tvar, LV_PART.MAIN, await arc_style.get_var()
|
||||
)
|
||||
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
|
||||
lw = Widget.create(iid, tvar, arc_indicator_type)
|
||||
lw.parent = scale_var
|
||||
await set_indicator_values(scale_var, lw, v)
|
||||
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
|
||||
|
||||
async def set_value(w: Widget):
|
||||
await set_indicator_values(w.parent, w, config)
|
||||
if (opa := config.get(CONF_OPA)) is None:
|
||||
return
|
||||
opa = await opacity.process(opa)
|
||||
if w.type is arc_indicator_type:
|
||||
style = await cg.get_variable(ID(_arc_style_name(str(w.var))))
|
||||
lv.style_set_arc_opa(style, opa)
|
||||
lv_obj.invalidate(w.parent)
|
||||
else:
|
||||
lv_obj.set_style_opa(w.obj, opa, LV_PART.MAIN)
|
||||
|
||||
return await action_to_code(widget, set_value, action_id, template_arg, args)
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
import importlib.util
|
||||
from typing import Any, Self
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components.const import CONF_COLOR_DEPTH
|
||||
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
|
||||
import esphome.config_validation as cv
|
||||
@@ -23,6 +25,7 @@ from esphome.const import (
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
@@ -306,6 +309,57 @@ def model_schema_extractor(
|
||||
return decorate
|
||||
|
||||
|
||||
def _is_pin_expander(key: str) -> bool:
|
||||
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
|
||||
from esphome import loader
|
||||
|
||||
if key not in pins.PIN_SCHEMA_REGISTRY:
|
||||
if importlib.util.find_spec(f"esphome.components.{key}") is None:
|
||||
return False
|
||||
# Loading the component registers its pin schema. If it fails to load (the
|
||||
# loader logs why), still require it rather than quietly dropping it.
|
||||
if loader.get_component(key) is None:
|
||||
return True
|
||||
return key in pins.PIN_SCHEMA_REGISTRY
|
||||
|
||||
|
||||
def _pin_expanders(value: Any) -> set[str]:
|
||||
"""Find the pin expander components used by any pin configs in a model's defaults."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return set().union(*(_pin_expanders(v) for v in value))
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
found = set().union(*(_pin_expanders(v) for v in value.values()))
|
||||
if CONF_NUMBER in value:
|
||||
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
|
||||
return found
|
||||
|
||||
|
||||
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component a display model requires is not configured.
|
||||
These are the components in its ``requires`` default plus any pin expander used by its
|
||||
default pins, which is needed even if the user config overrides those pins.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
|
||||
if missing := {x for x in requirements if x not in global_config}:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
|
||||
|
||||
class DriverChip:
|
||||
"""
|
||||
A class representing a MIPI DBI driver chip model.
|
||||
@@ -671,32 +725,14 @@ class DriverChip:
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
|
||||
Check that the components this model needs are configured, see check_model_requirements().
|
||||
Also logs a warning if the model is deprecated.
|
||||
"""
|
||||
if deprecation_reason := self.get_default("deprecation_reason"):
|
||||
LOGGER.warning(
|
||||
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
||||
)
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
|
||||
|
||||
def requires_buffer(config) -> bool:
|
||||
|
||||
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
|
||||
}
|
||||
|
||||
|
||||
def model_schema(config: ConfigType) -> cv.All:
|
||||
def model_schema(config: ConfigType) -> cv.Schema:
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
# Dimensions are optional if the model has a default width
|
||||
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
|
||||
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
|
||||
schema = display.FULL_DISPLAY_SCHEMA.extend(
|
||||
return display.FULL_DISPLAY_SCHEMA.extend(
|
||||
{
|
||||
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
cv.GenerateID(): cv.declare_id(MipiDsi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
|
||||
}
|
||||
)
|
||||
return cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32P4]),
|
||||
)
|
||||
|
||||
|
||||
@model_schema_extractor(MODELS, model_schema)
|
||||
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
|
||||
config
|
||||
)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ DsiDriverChip(
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="730Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="960Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
@@ -122,7 +122,7 @@ DsiDriverChip(
|
||||
pclk_frequency="70MHz",
|
||||
lane_bit_rate="965Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
|
||||
@@ -17,7 +17,7 @@ DsiDriverChip(
|
||||
color_order="RGB",
|
||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||
reset_pin={"xl9535": None, "number": 2},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
schema = model_schema(config)
|
||||
config = cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
|
||||
)(config)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ ST7701S(
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 17},
|
||||
reset_pin={"xl9535": None, "number": 5},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
pclk_pin=41,
|
||||
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
|
||||
invert_colors=False,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 3},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
de_pin=45,
|
||||
hsync_pin=47,
|
||||
vsync_pin=41,
|
||||
|
||||
@@ -115,7 +115,7 @@ st7701s.extend(
|
||||
vsync_pulse_width=8,
|
||||
vsync_back_porch=20,
|
||||
cs_pin={"pca9554": None, "number": 4},
|
||||
requires={"psram", "pca9554"},
|
||||
requires={"psram"},
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
vsync_pin=17,
|
||||
|
||||
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
|
||||
pclk_frequency="16MHz",
|
||||
reset_pin={"ch422g": None, "number": 3},
|
||||
enable_pin={"ch422g": None, "number": 2},
|
||||
requires={"psram", "ch422g"},
|
||||
requires={"psram"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
|
||||
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MipiSpi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
||||
COLOR_ORDERS, upper=True
|
||||
),
|
||||
|
||||
@@ -22,7 +22,6 @@ DriverChip(
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
use_axis_flips=True,
|
||||
reset_delay=20,
|
||||
requires={"pca9554"},
|
||||
initsequence=(
|
||||
delay(100),
|
||||
(0xFF, 0x20, 0x10, 0x10),
|
||||
|
||||
@@ -12,8 +12,12 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UART_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_USE_FAHRENHEIT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -69,7 +73,17 @@ def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
|
||||
def _temperature_unit(config: ConfigType, full_config: ConfigType) -> str:
|
||||
# The hub's use_fahrenheit picks the unit in traits(); a legacy climate-owned hub has no option
|
||||
if (hub_id := config.get(CONF_MITSUBISHI_CN105_ID)) is None:
|
||||
return UNIT_CELSIUS
|
||||
hub = fv.get_declaring_config(hub_id, full_config)
|
||||
return UNIT_FAHRENHEIT if hub.get(CONF_USE_FAHRENHEIT) else UNIT_CELSIUS
|
||||
|
||||
|
||||
_BASE_SCHEMA = climate.climate_schema(
|
||||
MitsubishiCN105Climate, fixed_temperature_unit=_temperature_unit
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from esphome import automation
|
||||
from esphome import automation, preferences
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import logger, network, socket
|
||||
from esphome.components.const import CONF_VERIFY_SSL
|
||||
@@ -107,6 +107,7 @@ def AUTO_LOAD():
|
||||
CONF_TLS = "tls"
|
||||
CONF_IDF_SEND_ASYNC = "idf_send_async"
|
||||
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
|
||||
CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions"
|
||||
CONF_TRANSPORT = "transport"
|
||||
CONF_WS_PATH = "ws_path"
|
||||
|
||||
@@ -304,6 +305,10 @@ def validate_config(value):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_WS_PATH}' is only valid when 'transport' is 'ws' or 'wss'"
|
||||
)
|
||||
if value.get(CONF_PERSIST_SUBSCRIPTIONS) and value.get(CONF_CLEAN_SESSION):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_PERSIST_SUBSCRIPTIONS}' requires '{CONF_CLEAN_SESSION}: false'"
|
||||
)
|
||||
|
||||
# Populate default fields
|
||||
out = value.copy()
|
||||
@@ -394,6 +399,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_USERNAME, default=""): cv.string,
|
||||
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
|
||||
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PERSIST_SUBSCRIPTIONS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_CLIENT_ID): cv.string,
|
||||
cv.Optional(
|
||||
CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP
|
||||
@@ -666,6 +672,10 @@ async def to_code(config):
|
||||
cg.add(var.set_username(config[CONF_USERNAME]))
|
||||
cg.add(var.set_password(config[CONF_PASSWORD]))
|
||||
cg.add(var.set_clean_session(config[CONF_CLEAN_SESSION]))
|
||||
if config[CONF_PERSIST_SUBSCRIPTIONS]:
|
||||
cg.add_define("USE_MQTT_SESSION_PERSISTENCE")
|
||||
# The session fingerprint, a uint32_t kept in RTC memory
|
||||
preferences.reserve_preferences(1, 4, in_flash=False)
|
||||
cg.add_optional(var.set_client_id, config.get(CONF_CLIENT_ID))
|
||||
|
||||
discovery = config[CONF_DISCOVERY]
|
||||
|
||||
@@ -39,7 +39,7 @@ class MQTTBackend {
|
||||
public:
|
||||
using on_connect_callback_t = void(bool session_present);
|
||||
using on_disconnect_callback_t = void(MQTTClientDisconnectReason reason);
|
||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t qos);
|
||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t return_code);
|
||||
using on_unsubscribe_callback_t = void(uint16_t packet_id);
|
||||
using on_message_callback_t = void(const char *topic, const char *payload, size_t len, size_t index, size_t total);
|
||||
using on_publish_user_callback_t = void(uint16_t packet_id);
|
||||
|
||||
@@ -170,8 +170,9 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) {
|
||||
|
||||
case MQTT_EVENT_SUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
// hardcode QoS to 0. QoS is not used in this context but required to mirror the AsyncMqtt interface
|
||||
this->on_subscribe_.call((int) event.msg_id, 0);
|
||||
// One topic per SUBSCRIBE, so data[0] is its return code
|
||||
this->on_subscribe_.call((int) event.msg_id,
|
||||
event.data.empty() ? SUBACK_FAILURE : static_cast<uint8_t>(event.data[0]));
|
||||
break;
|
||||
case MQTT_EVENT_UNSUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
@@ -224,6 +225,16 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b
|
||||
if (msg_id == instance->tracked_msg_id_.load())
|
||||
instance->tracked_acked_.store(1);
|
||||
}
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Also counted here: the queue holds 31 events, too few for the SUBACKs of a device with many command topics
|
||||
if (event_id == MQTT_EVENT_CONNECTED) {
|
||||
instance->subacks_.reset();
|
||||
} else if (event_id == MQTT_EVENT_SUBSCRIBED) {
|
||||
const auto *mqtt_event = static_cast<esp_mqtt_event_t *>(event_data);
|
||||
// One topic per SUBSCRIBE, so data[0] is its return code
|
||||
instance->subacks_.add(mqtt_event->data_len > 0 ? static_cast<uint8_t>(mqtt_event->data[0]) : SUBACK_FAILURE);
|
||||
}
|
||||
#endif
|
||||
// queue event to decouple processing from ESP-IDF MQTT task to main loop
|
||||
auto *event = instance->mqtt_event_pool_.allocate();
|
||||
if (event == nullptr) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
|
||||
namespace esphome::mqtt {
|
||||
|
||||
@@ -214,6 +215,9 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
||||
using MQTTBackend::publish;
|
||||
bool publish_tracked(const MQTTMessage &message) final;
|
||||
bool publish_acknowledged() const final { return this->tracked_acked_.load(std::memory_order_acquire) != 0; }
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
const SubackCounter &get_subacks() const { return this->subacks_; }
|
||||
#endif
|
||||
|
||||
void loop() final;
|
||||
|
||||
@@ -294,6 +298,10 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
||||
std::atomic<uint16_t> acked_msg_id_{0};
|
||||
// uint8_t, not bool: GCC on Xtensa emits an out-of-line call for atomic<bool>
|
||||
std::atomic<uint8_t> tracked_acked_{0};
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Counted in the ESP-IDF MQTT task, see mqtt_event_handler()
|
||||
SubackCounter subacks_;
|
||||
#endif
|
||||
// Pool sized to queue capacity (SIZE-1) because LockFreeQueue<T,N> is a ring
|
||||
// buffer that holds N-1 elements (one slot distinguishes full from empty).
|
||||
// This guarantees allocate() returns nullptr before push() can fail, which:
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
#ifdef USE_ESP8266
|
||||
#include "esphome/core/preferences.h"
|
||||
#elif defined(USE_ESP32)
|
||||
#include <esp_attr.h>
|
||||
#include <soc/soc_caps.h>
|
||||
#include "esphome/core/preferences_rtc.h"
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DASHBOARD_IMPORT
|
||||
#include "esphome/components/dashboard_import/dashboard_import.h"
|
||||
#endif
|
||||
@@ -32,6 +42,39 @@ ESPHOME_LOG_TAG(TAG, "mqtt");
|
||||
// Maximum number of MQTT component resends per loop iteration.
|
||||
// Limits work to avoid triggering the task watchdog on reconnect.
|
||||
static constexpr uint8_t MAX_RESENDS_PER_LOOP = 8;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
static constexpr uint32_t SESSION_FINGERPRINT_HASH = fnv1a_hash("mqtt_session_fingerprint");
|
||||
#ifdef USE_ESP8266
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static ESPPreferenceObject s_session_fingerprint_pref;
|
||||
#elif defined(USE_ESP32) && SOC_RTC_MEM_SUPPORTED
|
||||
#define USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT
|
||||
// Own 8 bytes rather than core's RTC preference pool, which would add 248 bytes of RTC RAM (usable memory on
|
||||
// variants with RTC fast memory only). The fingerprint and its checksum; power-on garbage fails the checksum.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RTC_NOINIT_ATTR uint32_t s_session_fingerprint[2];
|
||||
#endif
|
||||
|
||||
// RTC memory where available; RAM only otherwise, so a reset resubscribes
|
||||
static void load_session_fingerprint([[maybe_unused]] uint32_t *fingerprint) {
|
||||
#ifdef USE_ESP8266
|
||||
s_session_fingerprint_pref = global_preferences->make_preference<uint32_t>(SESSION_FINGERPRINT_HASH, false);
|
||||
s_session_fingerprint_pref.load(fingerprint);
|
||||
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
|
||||
rtc_pref_decode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<uint8_t *>(fingerprint),
|
||||
sizeof(*fingerprint));
|
||||
#endif
|
||||
}
|
||||
|
||||
static void store_session_fingerprint([[maybe_unused]] const uint32_t *fingerprint) {
|
||||
#ifdef USE_ESP8266
|
||||
s_session_fingerprint_pref.save(fingerprint);
|
||||
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
|
||||
rtc_pref_encode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<const uint8_t *>(fingerprint),
|
||||
sizeof(*fingerprint));
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
|
||||
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
|
||||
@@ -80,6 +123,19 @@ void MQTTClientComponent::setup() {
|
||||
this->state_ = MQTT_CLIENT_DISCONNECTED;
|
||||
this->disconnect_reason_ = reason;
|
||||
});
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->mqtt_backend_.set_on_connect([this](bool session_present) {
|
||||
this->session_present_ = session_present;
|
||||
#ifndef USE_ESP32
|
||||
// AsyncMqttClient delivers CONNACK and SUBACK in the same context, so the counter has one writer
|
||||
this->subacks_.reset();
|
||||
#endif
|
||||
});
|
||||
#ifndef USE_ESP32
|
||||
this->mqtt_backend_.set_on_subscribe([this](uint16_t, uint8_t return_code) { this->subacks_.add(return_code); });
|
||||
#endif
|
||||
load_session_fingerprint(&this->session_fingerprint_);
|
||||
#endif
|
||||
#ifdef USE_LOGGER
|
||||
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
|
||||
logger::global_logger->add_log_callback(
|
||||
@@ -361,6 +417,9 @@ void MQTTClientComponent::start_connect_() {
|
||||
this->last_will_.payload.c_str());
|
||||
}
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->session_present_ = false;
|
||||
#endif
|
||||
this->mqtt_backend_.connect();
|
||||
this->state_ = MQTT_CLIENT_CONNECTING;
|
||||
this->connect_begin_ = millis();
|
||||
@@ -385,14 +444,30 @@ void MQTTClientComponent::check_connected() {
|
||||
// MQTT Client needs some time to be fully set up.
|
||||
delay(100); // NOLINT
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->subscribes_sent_ = 0;
|
||||
const bool resumed =
|
||||
session_has_subscriptions(this->session_present_, this->session_fingerprint_, this->subscriptions_fingerprint_());
|
||||
this->subacks_settled_ = resumed;
|
||||
if (resumed) {
|
||||
for (auto &subscription : this->subscriptions_)
|
||||
subscription.subscribed = true;
|
||||
} else {
|
||||
// Untrusted until all are acknowledged again, so a reset part way through does not trust a partial session;
|
||||
// topics only an older firmware used stay subscribed in the session
|
||||
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
|
||||
}
|
||||
#endif
|
||||
this->resubscribe_subscriptions_();
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
// Best effort: a refused subscription is tried again at the next connection
|
||||
if (this->crash_log_offered_) {
|
||||
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
|
||||
format_esphome_topic(topic, "logs");
|
||||
this->subscribe_(topic, 0);
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Otherwise sent once the SUBACKs are in, so its own SUBACK is never counted
|
||||
if (resumed) {
|
||||
this->subscribe_crash_log_();
|
||||
}
|
||||
#else
|
||||
this->subscribe_crash_log_();
|
||||
#endif
|
||||
#endif
|
||||
this->send_device_info_();
|
||||
|
||||
@@ -441,7 +516,12 @@ void MQTTClientComponent::loop() {
|
||||
}
|
||||
|
||||
this->last_connected_ = now;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
if (this->resubscribe_subscriptions_() && !this->subacks_settled_)
|
||||
this->check_subacks_();
|
||||
#else
|
||||
this->resubscribe_subscriptions_();
|
||||
#endif
|
||||
|
||||
// Process pending resends for all MQTT components centrally
|
||||
// Limit work per loop iteration to avoid triggering task WDT on reconnect
|
||||
@@ -499,15 +579,69 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
|
||||
if (do_resub) {
|
||||
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
|
||||
sub->resubscribe_timeout = now;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Only the subscriptions the fingerprint covers are counted
|
||||
if (sub->subscribed)
|
||||
this->subscribes_sent_++;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
void MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
bool MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
bool all_subscribed = true;
|
||||
for (auto &subscription : this->subscriptions_) {
|
||||
this->resubscribe_subscription_(&subscription);
|
||||
all_subscribed &= subscription.subscribed;
|
||||
}
|
||||
return all_subscribed;
|
||||
}
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Out of line so the topic buffer is not part of loop()'s stack frame
|
||||
__attribute__((noinline)) uint32_t MQTTClientComponent::subscriptions_fingerprint_() {
|
||||
// Seeded with the config hash, so a new firmware config sends every subscription once
|
||||
uint32_t fingerprint = App.get_config_hash();
|
||||
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||
for (const auto &subscription : this->subscriptions_)
|
||||
fingerprint = add_subscription_fingerprint(fingerprint, subscription.topic.build(buf).c_str(), subscription.qos);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
void MQTTClientComponent::check_subacks_() {
|
||||
#ifdef USE_ESP32
|
||||
const SubackTally tally = this->mqtt_backend_.get_subacks().tally(this->subscribes_sent_);
|
||||
#else
|
||||
const SubackTally tally = this->subacks_.tally(this->subscribes_sent_);
|
||||
#endif
|
||||
if (tally == SubackTally::SUBACK_TALLY_PENDING)
|
||||
return;
|
||||
this->subacks_settled_ = true;
|
||||
// Trusted only once the broker granted every SUBSCRIBE, so a reset or a lost link before that sends them again,
|
||||
// and a refused one is tried again at the next connect
|
||||
if (tally == SubackTally::SUBACK_TALLY_ALL_GRANTED)
|
||||
this->set_session_fingerprint_(this->subscriptions_fingerprint_());
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
this->subscribe_crash_log_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void MQTTClientComponent::set_session_fingerprint_(uint32_t fingerprint) {
|
||||
if (this->session_fingerprint_ == fingerprint)
|
||||
return;
|
||||
this->session_fingerprint_ = fingerprint;
|
||||
store_session_fingerprint(&this->session_fingerprint_);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
void MQTTClientComponent::subscribe_crash_log_() {
|
||||
// Best effort: a refused subscription is tried again at the next connection
|
||||
if (!this->crash_log_offered_)
|
||||
return;
|
||||
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
|
||||
format_esphome_topic(topic, "logs");
|
||||
this->subscribe_(topic, 0);
|
||||
}
|
||||
|
||||
void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) {
|
||||
// Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash
|
||||
// log for every line it logs
|
||||
@@ -572,6 +706,10 @@ __attribute__((noinline)) static bool topic_equals(const MQTTTopicSource &source
|
||||
}
|
||||
|
||||
void MQTTClientComponent::unsubscribe(const std::string &topic) {
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Before the broker drops the topic, so a reset in between cannot leave a matching fingerprint
|
||||
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
|
||||
#endif
|
||||
bool ret = this->mqtt_backend_.unsubscribe(topic.c_str());
|
||||
yield();
|
||||
if (ret) {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
#elif defined(USE_LIBRETINY)
|
||||
#include "mqtt_backend_libretiny.h"
|
||||
#endif
|
||||
#if defined(USE_MQTT_SESSION_PERSISTENCE) && !defined(USE_ESP32)
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
#endif
|
||||
#include "lwip/ip_addr.h"
|
||||
|
||||
#include <span>
|
||||
@@ -380,10 +383,18 @@ class MQTTClientComponent final : public Component {
|
||||
}
|
||||
void erase_removed_subscriptions_();
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
void subscribe_crash_log_();
|
||||
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
|
||||
#endif
|
||||
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
|
||||
void resubscribe_subscriptions_();
|
||||
/// Returns true when every subscription is subscribed
|
||||
bool resubscribe_subscriptions_();
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
uint32_t subscriptions_fingerprint_();
|
||||
/// Once every SUBACK of this connection is in, saves the fingerprint if all were granted
|
||||
void check_subacks_();
|
||||
void set_session_fingerprint_(uint32_t fingerprint);
|
||||
#endif
|
||||
|
||||
MQTTCredentials credentials_;
|
||||
/// The last will message. Disabled optional denotes it being default and
|
||||
@@ -440,6 +451,18 @@ class MQTTClientComponent final : public Component {
|
||||
bool wait_for_connection_{false};
|
||||
bool dispatching_{false};
|
||||
bool removed_while_dispatching_{false};
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
bool session_present_{false};
|
||||
/// Set once every SUBACK of this connection is in (or the session was resumed without subscribing)
|
||||
bool subacks_settled_{false};
|
||||
/// SUBSCRIBEs sent on this connection for the subscriptions the fingerprint covers
|
||||
uint16_t subscribes_sent_{0};
|
||||
#ifndef USE_ESP32
|
||||
SubackCounter subacks_;
|
||||
#endif
|
||||
/// Fingerprint of the set the broker session is known to hold in full; 0 while a rebuild is in progress
|
||||
uint32_t session_fingerprint_{0};
|
||||
#endif
|
||||
};
|
||||
|
||||
extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::mqtt {
|
||||
|
||||
/// Adds one subscription to a fingerprint of a set of subscriptions; the order they are added in does not matter.
|
||||
inline uint32_t add_subscription_fingerprint(uint32_t fingerprint, const char *topic, uint8_t qos) {
|
||||
return fingerprint + fnv1a_hash_extend(fnv1a_hash(topic), qos);
|
||||
}
|
||||
|
||||
/// Session fingerprint while a rebuild is in progress, and after a power loss or a cold boot.
|
||||
static constexpr uint32_t UNTRUSTED_SESSION_FINGERPRINT = 0;
|
||||
|
||||
/// True when a resumed session holds in full the subscriptions it is trusted with, and they are the current ones.
|
||||
constexpr bool session_has_subscriptions(bool session_present, uint32_t trusted, uint32_t current) {
|
||||
return session_present && trusted != UNTRUSTED_SESSION_FINGERPRINT && trusted == current;
|
||||
}
|
||||
|
||||
/// First SUBACK return code that refuses a subscription; also stands in for a SUBACK with no return code.
|
||||
static constexpr uint8_t SUBACK_FAILURE = 0x80;
|
||||
|
||||
/// True when a SUBACK return code grants the subscription.
|
||||
constexpr bool subscribe_granted(uint8_t return_code) { return return_code < SUBACK_FAILURE; }
|
||||
|
||||
enum class SubackTally : uint8_t {
|
||||
SUBACK_TALLY_PENDING,
|
||||
SUBACK_TALLY_ALL_GRANTED,
|
||||
SUBACK_TALLY_NOT_ALL_GRANTED,
|
||||
};
|
||||
|
||||
/// SUBACKs received on the current connection. Written only by the context that delivers broker events (so plain
|
||||
/// loads and stores, no read-modify-write), read by the main loop.
|
||||
class SubackCounter {
|
||||
public:
|
||||
void reset() {
|
||||
this->received_.store(0, std::memory_order_relaxed);
|
||||
this->granted_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
void add(uint8_t return_code) {
|
||||
if (subscribe_granted(return_code))
|
||||
this->granted_.store(this->granted_.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||
// Released after granted_, so a reader that sees this SUBACK in received_ also sees it in granted_
|
||||
this->received_.store(this->received_.load(std::memory_order_relaxed) + 1, std::memory_order_release);
|
||||
}
|
||||
/// Compares the SUBACKs received with the number of SUBSCRIBEs sent on this connection.
|
||||
SubackTally tally(uint16_t sent) const {
|
||||
const uint16_t received = this->received_.load(std::memory_order_acquire);
|
||||
if (received < sent)
|
||||
return SubackTally::SUBACK_TALLY_PENDING;
|
||||
// More than sent means a SUBACK from an earlier connection was counted, so none can be trusted
|
||||
return received == sent && this->granted_.load(std::memory_order_relaxed) == sent
|
||||
? SubackTally::SUBACK_TALLY_ALL_GRANTED
|
||||
: SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::atomic<uint16_t> received_{0};
|
||||
std::atomic<uint16_t> granted_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::mqtt
|
||||
@@ -31,6 +31,7 @@ CONF_PRECISION = "precision"
|
||||
CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake"
|
||||
CONF_START_UP_PAGE = "start_up_page"
|
||||
CONF_STARTUP_OVERRIDE_MS = "startup_override_ms"
|
||||
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX = "tft_upload_http_buffer_size_tx"
|
||||
CONF_TFT_UPLOAD_HTTP_RETRIES = "tft_upload_http_retries"
|
||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout"
|
||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout"
|
||||
|
||||
@@ -33,6 +33,7 @@ from .base_component import (
|
||||
CONF_SKIP_CONNECTION_HANDSHAKE,
|
||||
CONF_START_UP_PAGE,
|
||||
CONF_STARTUP_OVERRIDE_MS,
|
||||
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
|
||||
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
||||
@@ -53,10 +54,9 @@ def AUTO_LOAD() -> list[str]:
|
||||
|
||||
|
||||
def _validate_tft_upload(config):
|
||||
if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config and not CORE.is_esp32:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT} is only available on ESP32"
|
||||
)
|
||||
for key in (CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX):
|
||||
if key in config and not CORE.is_esp32:
|
||||
raise cv.Invalid(f"{key} is only available on ESP32")
|
||||
return config
|
||||
|
||||
|
||||
@@ -105,6 +105,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
),
|
||||
cv.Optional(CONF_START_UP_PAGE): cv.uint8_t,
|
||||
cv.Optional(CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX): cv.int_range(
|
||||
min=512, max=8192
|
||||
),
|
||||
cv.Optional(CONF_TFT_UPLOAD_HTTP_RETRIES): cv.int_range(min=1, max=255),
|
||||
cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
@@ -136,6 +139,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
(
|
||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
||||
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
||||
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
|
||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
||||
),
|
||||
CONF_TFT_URL,
|
||||
@@ -244,6 +248,14 @@ async def to_code(config):
|
||||
)
|
||||
)
|
||||
|
||||
# TFT upload HTTP TX buffer size (ESP32 only; unset = ESP-IDF default)
|
||||
if CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX in config:
|
||||
cg.add(
|
||||
var.set_tft_upload_http_buffer_size_tx(
|
||||
config[CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX]
|
||||
)
|
||||
)
|
||||
|
||||
# TFT upload HTTP retries (default: 5)
|
||||
if CONF_TFT_UPLOAD_HTTP_RETRIES in config:
|
||||
cg.add(
|
||||
|
||||
@@ -1089,6 +1089,19 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
||||
void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/**
|
||||
* @brief Set the HTTP client TX buffer size for TFT upload requests.
|
||||
*
|
||||
* The TX buffer must hold the request line, which carries the full URL. After a redirect
|
||||
* to a long signed URL (e.g. a GitHub release asset, ~600+ characters) the ESP-IDF default
|
||||
* is too small and the request fails with "HTTP_CLIENT: Out of buffer".
|
||||
*
|
||||
* A value of 0 keeps the ESP-IDF default (default).
|
||||
*
|
||||
* @param size Buffer size in bytes.
|
||||
*/
|
||||
void set_tft_upload_http_buffer_size_tx(uint16_t size) { this->tft_upload_http_buffer_size_tx_ = size; }
|
||||
|
||||
/**
|
||||
* @brief Set the watchdog timeout during TFT upload.
|
||||
*
|
||||
@@ -1573,7 +1586,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
||||
uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5)
|
||||
|
||||
#ifdef USE_ESP32
|
||||
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
|
||||
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
|
||||
uint16_t tft_upload_http_buffer_size_tx_{0}; ///< HTTP TX buffer in bytes (0 = ESP-IDF default)
|
||||
|
||||
/**
|
||||
* will request 4096 bytes chunks from the web server
|
||||
|
||||
@@ -283,6 +283,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
|
||||
.timeout_ms = static_cast<int>(this->tft_upload_http_timeout_),
|
||||
.disable_auto_redirect = false,
|
||||
.max_redirection_count = 10,
|
||||
.buffer_size_tx = this->tft_upload_http_buffer_size_tx_, // 0 = ESP-IDF default
|
||||
};
|
||||
// Initialize the HTTP client with the configuration
|
||||
esp_http_client_handle_t http_client = esp_http_client_init(&config);
|
||||
|
||||
@@ -44,6 +44,7 @@ from esphome.const import (
|
||||
CONF_INVERTED,
|
||||
CONF_NUMBER,
|
||||
CONF_OTA,
|
||||
CONF_PLATFORM,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SAFE_MODE,
|
||||
CONF_TOOLCHAIN,
|
||||
@@ -309,9 +310,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
|
||||
): cv.Schema(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_ENABLE_OTA_ROLLBACK, default=True
|
||||
): cv.boolean,
|
||||
# Defaults to whether MCUboot is present, see _final_validate
|
||||
cv.Optional(CONF_ENABLE_OTA_ROLLBACK): cv.boolean,
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -370,7 +370,21 @@ def _final_validate(config):
|
||||
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
|
||||
)
|
||||
|
||||
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
|
||||
# Rollback confirms a freshly flashed image through MCUboot, which is either
|
||||
# the bootloader or, for zephyr_mcumgr, a second stage after it.
|
||||
mcuboot = (
|
||||
config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
|
||||
or "zephyr_mcumgr" in CORE.loaded_integrations
|
||||
)
|
||||
if advanced.get(CONF_ENABLE_OTA_ROLLBACK) and not mcuboot:
|
||||
# Older configs set it explicitly; it never had an effect without MCUboot
|
||||
_LOGGER.warning(
|
||||
"'%s' requires MCUboot, which the '%s' bootloader does not have; ignoring it",
|
||||
CONF_ENABLE_OTA_ROLLBACK,
|
||||
config[KEY_BOOTLOADER],
|
||||
)
|
||||
advanced[CONF_ENABLE_OTA_ROLLBACK] = False
|
||||
if advanced.setdefault(CONF_ENABLE_OTA_ROLLBACK, mcuboot):
|
||||
# "disabled: false" means safe mode *is* enabled.
|
||||
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
|
||||
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
|
||||
@@ -834,26 +848,58 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: PYOCD upload
|
||||
|
||||
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
|
||||
from .ble_logger import is_ble_address
|
||||
from .ota import smpmgr_scan, smpmgr_upload
|
||||
|
||||
if host == "BLE":
|
||||
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
|
||||
|
||||
if is_ble_address(host):
|
||||
mcumgr_device = host
|
||||
|
||||
if mcumgr_device:
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
|
||||
return True # Handled: mcumgr OTA upload
|
||||
# MCUboot over USB CDC: the serial port speaks the MCUmgr SMP protocol
|
||||
asyncio.run(_smpmgr_upload(mcumgr_device))
|
||||
return True # Handled: mcumgr serial upload
|
||||
|
||||
# Deferred imports: bleak and friends are heavy, only load for BLE paths
|
||||
from .ble_logger import is_ble_address
|
||||
|
||||
if host == "BLE" or is_ble_address(host):
|
||||
asyncio.run(_upload_over_ble(config, host))
|
||||
return True # Handled: BLE OTA upload
|
||||
|
||||
return False # Not handled: let caller try default upload methods
|
||||
|
||||
|
||||
async def _smpmgr_upload(device: str) -> None:
|
||||
"""Upload the MCUboot application image over the MCUmgr SMP protocol."""
|
||||
from .ota import smpmgr_upload
|
||||
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
await smpmgr_upload(device, firmware)
|
||||
|
||||
|
||||
async def _upload_over_ble(config: ConfigType, host: str) -> None:
|
||||
"""Upload over the OTA protocol the firmware was built with."""
|
||||
from .ota import smpmgr_scan
|
||||
|
||||
# Read the config, not CORE.loaded_integrations: the validated config cache
|
||||
# skips validation, which is what fills loaded_integrations.
|
||||
if not any(
|
||||
ota.get(CONF_PLATFORM) == "adafruit_ble" for ota in config.get(CONF_OTA, [])
|
||||
):
|
||||
address = await smpmgr_scan(CORE.name) if host == "BLE" else host
|
||||
await _smpmgr_upload(address)
|
||||
return
|
||||
|
||||
from esphome.components.adafruit_ble import dfu
|
||||
|
||||
# A broken package must not leave a working device waiting in its bootloader
|
||||
package = dfu.load_firmware(CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE)
|
||||
address = host
|
||||
if host == "BLE":
|
||||
try:
|
||||
address = await smpmgr_scan(CORE.name)
|
||||
except EsphomeError as err:
|
||||
raise EsphomeError(f"{err}. {dfu.BOOTLOADER_HINT}") from err
|
||||
bootloader_address = await dfu.enter_dfu_mode(address)
|
||||
await dfu.dfu_upload(bootloader_address, package)
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
|
||||
address = devices[0]
|
||||
from .ble_logger import is_ble_address, logger_connect, logger_scan
|
||||
|
||||
@@ -9,16 +9,33 @@ ESPHOME_LOG_TAG(TAG, "rx8111");
|
||||
|
||||
// Indexed by the TSEL value stored in timer_clock_
|
||||
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
|
||||
PROGMEM_STRING_TABLE(FoutNames, "32768 Hz", "1024 Hz", "1 Hz", "off");
|
||||
PROGMEM_STRING_TABLE(EvinPullNames, "none", "pullup_500k", "pullup_1m", "pullup_10m", "pulldown_500k");
|
||||
PROGMEM_STRING_TABLE(BootFailureNames, "RX8111 battery configuration failed", "RX8111 FOUT configuration failed",
|
||||
"RX8111 EVIN configuration failed", ESP_LOG_MSG_COMM_FAIL, ESP_LOG_MSG_COMM_FAIL);
|
||||
|
||||
static constexpr uint8_t RX8111_REG_SEC = 0x10;
|
||||
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
|
||||
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
|
||||
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
|
||||
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
|
||||
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
|
||||
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
|
||||
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
|
||||
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
|
||||
static constexpr uint8_t RX8111_REG_TIMER_CONTROL = 0x2D;
|
||||
static constexpr uint8_t RX8111_REG_POWER_SWITCH = 0x32;
|
||||
static constexpr uint8_t RX8111_REG_TIMESTAMP_CONTROL = 0x34;
|
||||
static constexpr uint8_t RX8111_TIMESTAMP_EISEL = 0x04;
|
||||
static constexpr uint8_t RX8111_REG_EVIN = 0x2B;
|
||||
static constexpr uint8_t RX8111_EXTENSION_ETS = 0x04;
|
||||
static constexpr uint8_t RX8111_CONTROL_EIE = 0x04;
|
||||
static constexpr uint8_t RX8111_FLAG_EVF = 0x04;
|
||||
static constexpr uint8_t RX8111_POWER_CHGEN = 0x80;
|
||||
static constexpr uint8_t RX8111_POWER_INIEN = 0x40;
|
||||
static constexpr uint8_t RX8111_POWER_SWSEL = 0x0C;
|
||||
static constexpr uint8_t RX8111_POWER_VDD_ONLY = 0x08;
|
||||
static constexpr uint8_t RX8111_FOUT_FSEL = 0xC0;
|
||||
static constexpr uint8_t RX8111_TIMER_TMPIN = 0x02;
|
||||
static constexpr uint8_t RX8111_TIMER_MODE = 0x0F; // TBKON, TBKE, TMPIN, TSTP
|
||||
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
|
||||
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
|
||||
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
|
||||
@@ -36,17 +53,121 @@ static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
|
||||
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
|
||||
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
|
||||
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
|
||||
static constexpr uint32_t RX8111_BOOT_BATTERY_RETRY_ID = 3;
|
||||
static constexpr uint32_t RX8111_BOOT_FOUT_RETRY_ID = 4;
|
||||
static constexpr uint32_t RX8111_BOOT_EVENT_RETRY_ID = 5;
|
||||
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
|
||||
|
||||
void RX8111Component::setup() {
|
||||
if (this->battery_configured_)
|
||||
this->configure_boot_battery_();
|
||||
if (this->fout_configured_)
|
||||
this->configure_boot_fout_();
|
||||
if (this->event_configured_)
|
||||
this->configure_boot_event_();
|
||||
this->configure_boot_alarm_();
|
||||
this->configure_boot_timer_();
|
||||
this->read_time();
|
||||
if (this->alarm_enabled_ || this->watching_()) {
|
||||
if (this->watching_()) {
|
||||
this->start_event_polling_();
|
||||
}
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_event_() {
|
||||
const bool success = this->configure_event_();
|
||||
if (success) {
|
||||
this->event_boot_pending_ = false;
|
||||
if (this->watching_())
|
||||
this->start_event_polling_();
|
||||
} else {
|
||||
this->event_boot_pending_ = true;
|
||||
}
|
||||
this->boot_config_result_(BOOT_EVENT, success);
|
||||
}
|
||||
|
||||
bool RX8111Component::configure_event_() {
|
||||
// An explicit disable affects only /INT; leave retained capture settings untouched.
|
||||
if (!this->event_enabled_)
|
||||
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, 0);
|
||||
// ETS gates EVIN detection, not just timestamp publication. Preserve all other
|
||||
// extension bits, including FOUT, alarm and timer settings, and retained records.
|
||||
return this->update_register(RX8111_REG_EVIN, 0xFC, this->evin_settings_ & 0xFC) &&
|
||||
this->update_register(RX8111_REG_TIMESTAMP_CONTROL, RX8111_TIMESTAMP_EISEL, 0) &&
|
||||
this->update_register(RX8111_REG_EXTENSION, RX8111_EXTENSION_ETS, RX8111_EXTENSION_ETS) &&
|
||||
this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, RX8111_CONTROL_EIE);
|
||||
}
|
||||
|
||||
bool RX8111Component::clear_event_flag() {
|
||||
// FLAG is write-zero-to-clear; ones preserve other events, even if they latch during the write.
|
||||
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_EVF))) {
|
||||
this->set_warning_(WARN_EVENT_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
}
|
||||
this->event_flag_seen_ = false;
|
||||
this->clear_warning_(WARN_EVENT_FLAG_CLEAR);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_battery_() {
|
||||
// Own SWSEL as well as INIEN/CHGEN: 10 selects VDD when automatic switching is disabled
|
||||
// and is legal when enabled (unlike 11). Preserve SMPT and reserved bits.
|
||||
const uint8_t value = RX8111_POWER_VDD_ONLY | (this->battery_backup_ ? RX8111_POWER_INIEN : 0) |
|
||||
(this->battery_backup_ && this->battery_charging_ ? RX8111_POWER_CHGEN : 0);
|
||||
this->boot_config_result_(BOOT_BATTERY,
|
||||
this->update_register(RX8111_REG_POWER_SWITCH,
|
||||
RX8111_POWER_INIEN | RX8111_POWER_CHGEN | RX8111_POWER_SWSEL, value));
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_fout_() {
|
||||
// TMPIN routes timer interrupts to FOUT instead of /INT; do not silently change that routing.
|
||||
uint8_t timer_control;
|
||||
bool success = this->read_byte(RX8111_REG_TIMER_CONTROL, &timer_control);
|
||||
if (success && this->fout_frequency_ != 3 && (timer_control & RX8111_TIMER_TMPIN)) {
|
||||
ESP_LOGW(TAG, "FOUT is routed to the wake-up timer (TMPIN); cannot configure clock output");
|
||||
success = false;
|
||||
} else if (success) {
|
||||
success =
|
||||
this->update_register(RX8111_REG_EXTENSION, RX8111_FOUT_FSEL, static_cast<uint8_t>(this->fout_frequency_ << 6));
|
||||
}
|
||||
this->boot_config_result_(BOOT_FOUT, success);
|
||||
}
|
||||
|
||||
// A timer start clears TMPIN, which may be what failed FOUT; give it a fresh set of attempts.
|
||||
void RX8111Component::retry_fout_after_timer_start_() {
|
||||
if (!this->fout_configured_ || (this->warnings_ & WARN_FOUT) == 0)
|
||||
return;
|
||||
this->cancel_timeout(RX8111_BOOT_FOUT_RETRY_ID);
|
||||
this->boot_attempts_[BOOT_FOUT] = 0;
|
||||
this->configure_boot_fout_();
|
||||
}
|
||||
|
||||
void RX8111Component::boot_config_result_(BootFeature feature, bool success) {
|
||||
struct BootConfig {
|
||||
uint32_t retry_id;
|
||||
void (RX8111Component::*configure)();
|
||||
uint16_t warning;
|
||||
};
|
||||
static constexpr BootConfig CONFIGS[BOOT_FEATURE_COUNT] PROGMEM = {
|
||||
{RX8111_BOOT_BATTERY_RETRY_ID, &RX8111Component::configure_boot_battery_, WARN_BATTERY},
|
||||
{RX8111_BOOT_FOUT_RETRY_ID, &RX8111Component::configure_boot_fout_, WARN_FOUT},
|
||||
{RX8111_BOOT_EVENT_RETRY_ID, &RX8111Component::configure_boot_event_, WARN_EVENT},
|
||||
{RX8111_BOOT_ALARM_RETRY_ID, &RX8111Component::configure_boot_alarm_, WARN_ALARM},
|
||||
{RX8111_BOOT_TIMER_RETRY_ID, &RX8111Component::configure_boot_timer_, WARN_TIMER},
|
||||
};
|
||||
BootConfig config;
|
||||
progmem_memcpy(&config, &CONFIGS[feature], sizeof(config));
|
||||
const auto attempts = ++this->boot_attempts_[feature];
|
||||
if (success) {
|
||||
this->clear_warning_(config.warning);
|
||||
return;
|
||||
}
|
||||
this->set_warning_(config.warning, BootFailureNames::get_log_str(feature, BootFailureNames::LAST_INDEX));
|
||||
if (attempts < RX8111_BOOT_MAX_ATTEMPTS) {
|
||||
this->set_timeout(config.retry_id, this->boot_retry_delay_ms_,
|
||||
[this, configure = config.configure]() { (this->*configure)(); });
|
||||
}
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_alarm_() {
|
||||
bool success = true;
|
||||
if (this->alarm_enabled_) {
|
||||
@@ -74,7 +195,7 @@ void RX8111Component::configure_boot_alarm_() {
|
||||
} else if (this->alarm_explicitly_configured_) {
|
||||
success = this->set_alarm_interrupt_(false);
|
||||
}
|
||||
this->boot_config_result_(true, success);
|
||||
this->boot_config_result_(BOOT_ALARM, success);
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_timer_() {
|
||||
@@ -92,29 +213,10 @@ void RX8111Component::configure_boot_timer_() {
|
||||
this->timer_retained_event_pending_ = true;
|
||||
this->start_event_polling_();
|
||||
}
|
||||
if (success && start_timer)
|
||||
this->retry_fout_after_timer_start_();
|
||||
}
|
||||
this->boot_config_result_(false, success);
|
||||
}
|
||||
|
||||
void RX8111Component::boot_config_result_(bool alarm, bool success) {
|
||||
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
|
||||
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
|
||||
++attempts;
|
||||
if (success) {
|
||||
this->clear_warning_(warning);
|
||||
return;
|
||||
}
|
||||
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
|
||||
return;
|
||||
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
|
||||
[this, alarm]() {
|
||||
if (alarm) {
|
||||
this->configure_boot_alarm_();
|
||||
} else {
|
||||
this->configure_boot_timer_();
|
||||
}
|
||||
});
|
||||
this->boot_config_result_(BOOT_TIMER, success);
|
||||
}
|
||||
|
||||
// A runtime action supersedes pending boot programming of the same feature
|
||||
@@ -144,12 +246,12 @@ void RX8111Component::update() {
|
||||
this->check_event_flags_();
|
||||
}
|
||||
|
||||
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
|
||||
void RX8111Component::set_warning_(uint16_t bit, const LogString *message) {
|
||||
this->warnings_ |= bit;
|
||||
this->status_set_warning(message);
|
||||
}
|
||||
|
||||
void RX8111Component::clear_warning_(uint8_t bit) {
|
||||
void RX8111Component::clear_warning_(uint16_t bit) {
|
||||
if ((this->warnings_ & bit) == 0)
|
||||
return;
|
||||
this->warnings_ &= ~bit;
|
||||
@@ -160,6 +262,23 @@ void RX8111Component::clear_warning_(uint8_t bit) {
|
||||
void RX8111Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "RX8111:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
if (this->battery_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " Battery backup: %s, charging: %s", ONOFF(this->battery_backup_),
|
||||
ONOFF(this->battery_charging_));
|
||||
}
|
||||
if (this->fout_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " FOUT: %s", LOG_STR_ARG(FoutNames::get_log_str(this->fout_frequency_, FoutNames::LAST_INDEX)));
|
||||
}
|
||||
if (this->event_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " EVIN interrupt: %s", ONOFF(this->event_enabled_));
|
||||
if (this->event_enabled_) {
|
||||
ESP_LOGCONFIG(
|
||||
TAG, " EVIN: %s, filter: %u, pull: %s",
|
||||
(this->evin_settings_ & 0x80) ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"),
|
||||
(this->evin_settings_ >> 5) & 3,
|
||||
LOG_STR_ARG(EvinPullNames::get_log_str((this->evin_settings_ >> 2) & 7, EvinPullNames::LAST_INDEX)));
|
||||
}
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Alarm interrupt: %s\n"
|
||||
" Wake-up timer: %s",
|
||||
@@ -181,12 +300,20 @@ void RX8111Component::read_time() {
|
||||
}
|
||||
|
||||
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
||||
uint8_t flags;
|
||||
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
|
||||
if (this->time_write_invalid_) {
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||
return false;
|
||||
}
|
||||
uint8_t regs[2]; // Adjacent FLAG and CONTROL registers.
|
||||
if (!this->read_bytes(RX8111_REG_FLAG, regs, sizeof(regs))) {
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
}
|
||||
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
|
||||
if (regs[1] & RX8111_CTRL_STOP) {
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC counter stopped; not synchronizing"));
|
||||
return false;
|
||||
}
|
||||
if (regs[0] & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
|
||||
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
|
||||
return false;
|
||||
@@ -199,12 +326,12 @@ bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
||||
return false;
|
||||
}
|
||||
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
|
||||
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
||||
static constexpr uint8_t CALENDAR_MASKS[7] PROGMEM = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
||||
bool valid = true;
|
||||
for (size_t i = 0; i < sizeof(buf); i++) {
|
||||
if (i == 3)
|
||||
continue; // Weekday uses one-hot encoding, not BCD.
|
||||
buf[i] &= CALENDAR_MASKS[i];
|
||||
buf[i] &= progmem_read_byte(&CALENDAR_MASKS[i]);
|
||||
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
|
||||
}
|
||||
const uint8_t onehot = buf[3];
|
||||
@@ -236,23 +363,31 @@ void RX8111Component::write_time() {
|
||||
|
||||
uint8_t buf[7];
|
||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
|
||||
const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||
// Always try to restart the counter, even after a failed calendar write.
|
||||
const bool restarted = this->set_stop_bit_(false);
|
||||
if (!written || !restarted) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
this->time_write_invalid_ = true;
|
||||
const bool calendar_written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||
if (!this->finish_time_write_(calendar_written))
|
||||
return;
|
||||
}
|
||||
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
this->clear_warning_(WARN_WRITE_TIME);
|
||||
this->clear_warning_(WARN_CALENDAR);
|
||||
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
|
||||
now.minute, now.second);
|
||||
}
|
||||
|
||||
bool RX8111Component::finish_time_write_(bool calendar_written) {
|
||||
// STOP is the battery-backed invalidity marker, including across host resets.
|
||||
// Never release a partial or delayed snapshot; only a fresh trusted write can restart it.
|
||||
if (!calendar_written) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||
return false;
|
||||
}
|
||||
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS) || !this->set_stop_bit_(false)) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
}
|
||||
this->time_write_invalid_ = false;
|
||||
this->clear_warning_(WARN_WRITE_TIME);
|
||||
this->clear_warning_(WARN_CALENDAR);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RX8111Component::set_stop_bit_(bool stop) {
|
||||
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
|
||||
}
|
||||
@@ -383,6 +518,11 @@ bool RX8111Component::configure_timer_(bool clear_stale_flag) {
|
||||
if (!this->disable_timer_(extension, control))
|
||||
return false;
|
||||
|
||||
// Explicit starts own the timer mode: count in both power modes, do not pause,
|
||||
// and route interrupt pulses to /INT rather than FOUT. Leave reserved bits alone.
|
||||
if (!this->update_register(RX8111_REG_TIMER_CONTROL, RX8111_TIMER_MODE, 0))
|
||||
return false;
|
||||
|
||||
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
|
||||
static_cast<uint8_t>(this->timer_count_ >> 16)};
|
||||
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
|
||||
@@ -414,6 +554,7 @@ bool RX8111Component::start_timer() {
|
||||
this->timer_enabled_ = true;
|
||||
this->timer_retained_event_pending_ = false;
|
||||
this->start_event_polling_();
|
||||
this->retry_fout_after_timer_start_();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -445,6 +586,12 @@ bool RX8111Component::poll_event_flags_() {
|
||||
if (!this->read_byte(RX8111_REG_FLAG, &flags))
|
||||
return false;
|
||||
this->clear_warning_(WARN_EVENT_POLL);
|
||||
// EVF is not cleared here: users may use it to hold /INT asserted across sleep.
|
||||
const bool event_active = (flags & RX8111_FLAG_EVF) != 0;
|
||||
const bool event_rising =
|
||||
event_active && !this->event_flag_seen_ && this->event_enabled_ && !this->event_boot_pending_;
|
||||
if (this->event_enabled_ && !this->event_boot_pending_)
|
||||
this->event_flag_seen_ = event_active;
|
||||
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
|
||||
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
|
||||
// alarm_armed_ is only ever set while the alarm is enabled
|
||||
@@ -466,6 +613,8 @@ bool RX8111Component::poll_event_flags_() {
|
||||
// An alarm callback may stop the timer before its callback is delivered.
|
||||
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
|
||||
this->timer_callbacks_.call();
|
||||
if (event_rising && this->event_flag_seen_)
|
||||
this->event_callbacks_.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
|
||||
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
|
||||
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
|
||||
void set_battery_backup(bool enabled, bool charging) {
|
||||
this->battery_configured_ = true;
|
||||
this->battery_backup_ = enabled;
|
||||
this->battery_charging_ = charging;
|
||||
}
|
||||
void set_fout_frequency(uint8_t frequency) {
|
||||
this->fout_configured_ = true;
|
||||
this->fout_frequency_ = frequency;
|
||||
}
|
||||
void set_event_config(uint8_t settings) {
|
||||
this->event_configured_ = true;
|
||||
this->event_enabled_ = true;
|
||||
this->evin_settings_ = settings;
|
||||
}
|
||||
void set_event_disabled() {
|
||||
this->event_configured_ = true;
|
||||
this->event_enabled_ = false;
|
||||
}
|
||||
template<typename F> void add_on_event_callback(F &&callback) {
|
||||
this->event_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
bool clear_event_flag();
|
||||
template<typename F> void add_on_alarm_callback(F &&callback) {
|
||||
this->alarm_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -51,7 +73,7 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
|
||||
|
||||
// One bit per fault source, so a recovery in one path cannot hide another path's fault
|
||||
enum Warning : uint8_t {
|
||||
enum Warning : uint16_t {
|
||||
WARN_CALENDAR = 1 << 0,
|
||||
WARN_EVENT_POLL = 1 << 1,
|
||||
WARN_ALARM = 1 << 2,
|
||||
@@ -59,13 +81,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
WARN_WRITE_TIME = 1 << 4,
|
||||
WARN_ALARM_FLAG_CLEAR = 1 << 5,
|
||||
WARN_TIMER_FLAG_CLEAR = 1 << 6,
|
||||
WARN_BATTERY = 1 << 7,
|
||||
WARN_FOUT = 1 << 8,
|
||||
WARN_EVENT = 1 << 9,
|
||||
WARN_EVENT_FLAG_CLEAR = 1 << 10,
|
||||
};
|
||||
|
||||
bool set_stop_bit_(bool stop);
|
||||
bool finish_time_write_(bool calendar_written);
|
||||
bool read_calendar_(ESPTime &rtc_time);
|
||||
void set_warning_(uint8_t bit, const LogString *message);
|
||||
void clear_warning_(uint8_t bit);
|
||||
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
|
||||
void set_warning_(uint16_t bit, const LogString *message);
|
||||
void clear_warning_(uint16_t bit);
|
||||
void configure_boot_battery_();
|
||||
void configure_boot_fout_();
|
||||
void retry_fout_after_timer_start_();
|
||||
enum BootFeature : uint8_t { BOOT_BATTERY, BOOT_FOUT, BOOT_EVENT, BOOT_ALARM, BOOT_TIMER, BOOT_FEATURE_COUNT };
|
||||
void boot_config_result_(BootFeature feature, bool success);
|
||||
bool watching_() const {
|
||||
return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_ ||
|
||||
(this->event_enabled_ && !this->event_boot_pending_ && !this->event_callbacks_.empty());
|
||||
}
|
||||
void configure_boot_event_();
|
||||
bool configure_event_();
|
||||
void check_event_flags_();
|
||||
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
|
||||
bool set_alarm_interrupt_(bool enabled);
|
||||
@@ -75,33 +112,43 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
bool poll_event_flags_();
|
||||
void configure_boot_alarm_();
|
||||
void configure_boot_timer_();
|
||||
void boot_config_result_(bool alarm, bool success);
|
||||
void cancel_boot_retry_(bool alarm);
|
||||
|
||||
LazyCallbackManager<void()> alarm_callbacks_;
|
||||
LazyCallbackManager<void()> timer_callbacks_;
|
||||
LazyCallbackManager<void()> event_callbacks_;
|
||||
uint32_t timer_count_{0};
|
||||
uint32_t event_poll_interval_{1000};
|
||||
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
|
||||
uint8_t warnings_{0};
|
||||
uint16_t warnings_{0};
|
||||
uint8_t boot_attempts_[BOOT_FEATURE_COUNT]{};
|
||||
uint8_t evin_settings_{0};
|
||||
uint8_t fout_frequency_{3};
|
||||
uint8_t timer_clock_{2}; // 1 Hz
|
||||
uint8_t boot_alarm_attempts_{0};
|
||||
uint8_t boot_timer_attempts_{0};
|
||||
uint8_t alarm_second_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_day_{ALARM_FIELD_UNSET};
|
||||
bool timer_enabled_{false};
|
||||
bool timer_configured_{false};
|
||||
bool timer_flag_seen_{false};
|
||||
bool timer_retained_event_pending_{false};
|
||||
bool event_polling_started_{false};
|
||||
bool boot_alarm_event_seen_{false};
|
||||
bool alarm_enabled_{false};
|
||||
bool alarm_explicitly_configured_{false};
|
||||
bool alarm_flag_seen_{false};
|
||||
bool alarm_armed_{false};
|
||||
bool time_write_invalid_ : 1 {false};
|
||||
bool battery_configured_ : 1 {false};
|
||||
bool battery_backup_ : 1 {false};
|
||||
bool battery_charging_ : 1 {false};
|
||||
bool fout_configured_ : 1 {false};
|
||||
bool event_configured_ : 1 {false};
|
||||
bool event_enabled_ : 1 {false};
|
||||
bool event_flag_seen_ : 1 {false};
|
||||
bool event_boot_pending_ : 1 {false};
|
||||
bool timer_enabled_ : 1 {false};
|
||||
bool timer_configured_ : 1 {false};
|
||||
bool timer_flag_seen_ : 1 {false};
|
||||
bool timer_retained_event_pending_ : 1 {false};
|
||||
bool event_polling_started_ : 1 {false};
|
||||
bool boot_alarm_event_seen_ : 1 {false};
|
||||
bool alarm_enabled_ : 1 {false};
|
||||
bool alarm_explicitly_configured_ : 1 {false};
|
||||
bool alarm_flag_seen_ : 1 {false};
|
||||
bool alarm_armed_ : 1 {false};
|
||||
};
|
||||
|
||||
} // namespace esphome::rx8111
|
||||
@@ -10,6 +10,7 @@ from esphome.const import (
|
||||
CONF_HOUR,
|
||||
CONF_ID,
|
||||
CONF_MINUTE,
|
||||
CONF_ON_EVENT,
|
||||
CONF_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
@@ -20,6 +21,14 @@ DEPENDENCIES = ["i2c"]
|
||||
rx8111_ns = cg.esphome_ns.namespace("rx8111")
|
||||
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
|
||||
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
|
||||
FOUT_FREQUENCIES = {"32768hz": 0, "1024hz": 1, "1hz": 2, "off": 3}
|
||||
EVIN_PULLS = {
|
||||
"none": 0,
|
||||
"pullup_500k": 0x04,
|
||||
"pullup_1m": 0x08,
|
||||
"pullup_10m": 0x0C,
|
||||
"pulldown_500k": 0x10,
|
||||
}
|
||||
# set_alarm() ignores a field set to this value
|
||||
ALARM_FIELD_UNSET = 0xFF
|
||||
|
||||
@@ -36,6 +45,13 @@ CONF_TIMER_ENABLED = "timer_enabled"
|
||||
CONF_ON_TIMER = "on_timer"
|
||||
CONF_WEEKDAY = "weekday"
|
||||
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
|
||||
CONF_BATTERY_BACKUP = "battery_backup"
|
||||
CONF_BATTERY_CHARGING = "battery_charging"
|
||||
CONF_FOUT_FREQUENCY = "fout_frequency"
|
||||
CONF_EVENT_ENABLED = "event_enabled"
|
||||
CONF_EVENT_LEVEL = "event_level"
|
||||
CONF_EVIN_FILTER = "evin_filter"
|
||||
CONF_EVIN_PULL = "evin_pull"
|
||||
|
||||
_ALARM_FIELDS = (
|
||||
CONF_ALARM_SECOND,
|
||||
@@ -47,6 +63,7 @@ _ALARM_FIELDS = (
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_EVENT, "add_on_event_callback"),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +79,16 @@ def _validate_alarm_config(config: ConfigType) -> ConfigType:
|
||||
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
|
||||
):
|
||||
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
|
||||
if CONF_BATTERY_CHARGING in config and CONF_BATTERY_BACKUP not in config:
|
||||
raise cv.Invalid("battery_charging requires an explicit battery_backup setting")
|
||||
if config.get(CONF_BATTERY_CHARGING) and not config.get(CONF_BATTERY_BACKUP):
|
||||
raise cv.Invalid("battery_charging: true requires battery_backup: true")
|
||||
if CONF_ON_EVENT in config and not config.get(CONF_EVENT_ENABLED):
|
||||
raise cv.Invalid("on_event requires event_enabled: true")
|
||||
if any(
|
||||
k in config for k in (CONF_EVENT_LEVEL, CONF_EVIN_FILTER, CONF_EVIN_PULL)
|
||||
) and not config.get(CONF_EVENT_ENABLED):
|
||||
raise cv.Invalid("EVIN settings require event_enabled: true")
|
||||
return config
|
||||
|
||||
|
||||
@@ -86,6 +113,16 @@ CONFIG_SCHEMA = cv.All(
|
||||
TIMER_CLOCKS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
|
||||
cv.Optional(CONF_BATTERY_BACKUP): cv.boolean,
|
||||
cv.Optional(CONF_BATTERY_CHARGING): cv.boolean,
|
||||
cv.Optional(CONF_FOUT_FREQUENCY): cv.enum(FOUT_FREQUENCIES, lower=True),
|
||||
cv.Optional(CONF_EVENT_ENABLED): cv.boolean,
|
||||
cv.Optional(CONF_EVENT_LEVEL): cv.enum(
|
||||
{"low": 0, "high": 0x80}, lower=True
|
||||
),
|
||||
cv.Optional(CONF_EVIN_FILTER): cv.int_range(min=0, max=3),
|
||||
cv.Optional(CONF_EVIN_PULL): cv.enum(EVIN_PULLS, lower=True),
|
||||
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
|
||||
# Below 500 ms a scheduler interval costs more than it saves
|
||||
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
|
||||
@@ -107,6 +144,7 @@ for _method in (
|
||||
"read_time",
|
||||
"clear_alarm_flag",
|
||||
"clear_timer_flag",
|
||||
"clear_event_flag",
|
||||
"unset_alarm",
|
||||
"stop_timer",
|
||||
"start_timer",
|
||||
@@ -184,6 +222,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = await i2c.new_i2c_component(config)
|
||||
await time.register_time(var, config)
|
||||
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
|
||||
if (backup := config.get(CONF_BATTERY_BACKUP)) is not None:
|
||||
cg.add(var.set_battery_backup(backup, config.get(CONF_BATTERY_CHARGING, False)))
|
||||
cg.add_optional(var.set_fout_frequency, config.get(CONF_FOUT_FREQUENCY))
|
||||
if config.get(CONF_EVENT_ENABLED):
|
||||
level = config[CONF_EVENT_LEVEL].enum_value if CONF_EVENT_LEVEL in config else 0
|
||||
pull = config[CONF_EVIN_PULL].enum_value if CONF_EVIN_PULL in config else 0
|
||||
filter_bits = config.get(CONF_EVIN_FILTER, 0) << 5
|
||||
cg.add(var.set_event_config(level | filter_bits | pull))
|
||||
elif CONF_EVENT_ENABLED in config:
|
||||
cg.add(var.set_event_disabled())
|
||||
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
|
||||
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
|
||||
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
|
||||
|
||||
@@ -124,6 +124,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
|
||||
from esphome.core.entity_helpers import (
|
||||
FollowEntity,
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
lazy_load_validator,
|
||||
@@ -1109,6 +1110,10 @@ def sub_sensors(
|
||||
return SubEntities(new_sensor, config, parent)
|
||||
|
||||
|
||||
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
|
||||
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
|
||||
|
||||
|
||||
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(Sensor),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, sensor
|
||||
@@ -27,11 +29,22 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_TEMPERATURE_HIGH,
|
||||
CONF_TARGET_TEMPERATURE_LOW,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from esphome.core.entity_helpers import (
|
||||
UNRESOLVED_PROPERTY,
|
||||
convert_temperature_keys,
|
||||
entity_temperature_unit,
|
||||
resolve_inherited_property,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import template_ns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_CURRENT_HUMIDITY = "current_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
CONF_SUPPORTS_ACTION = "supports_action"
|
||||
@@ -190,6 +203,9 @@ def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
||||
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
||||
)
|
||||
convert_temperature_keys(
|
||||
initial_state, climate.TARGET_TEMPERATURE_KEYS, entity_temperature_unit(config)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -199,9 +215,9 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||
cv.Exclusive(
|
||||
@@ -216,7 +232,7 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
climate.climate_schema(TemplateClimate)
|
||||
climate.climate_schema(TemplateClimate, temperature_unit=True)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
@@ -290,6 +306,30 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _sensor_reports_in_the_entity_unit(config: ConfigType) -> ConfigType:
|
||||
"""A sensor's state is copied as is, so a temperature sensor must report in the climate's unit."""
|
||||
if (sensor_id := config.get(CONF_SENSOR)) is None:
|
||||
return config
|
||||
unit = entity_temperature_unit(config)
|
||||
sensor_unit = resolve_inherited_property(sensor_id, CONF_UNIT_OF_MEASUREMENT)
|
||||
if sensor_unit is UNRESOLVED_PROPERTY:
|
||||
_LOGGER.warning(
|
||||
"Could not determine the unit of sensor '%s'; it must report in %s",
|
||||
sensor_id,
|
||||
unit,
|
||||
)
|
||||
return config
|
||||
if sensor_unit in TEMPERATURE_UNITS and sensor_unit != unit:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_SENSOR}' reports in {sensor_unit} but the climate reports in {unit}",
|
||||
path=[CONF_SENSOR],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _sensor_reports_in_the_entity_unit
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -367,11 +407,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
||||
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
||||
@@ -400,16 +440,13 @@ automation.register_apply_action(
|
||||
"climate.template.publish",
|
||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||
automation.ApplyField(
|
||||
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
"current_temperature = {}",
|
||||
cg.float_,
|
||||
temperature_in_entity_unit,
|
||||
),
|
||||
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
|
||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
|
||||
),
|
||||
*climate.TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
|
||||
automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
@@ -22,6 +23,12 @@ class TemplateClimate final : public climate::Climate, public Component {
|
||||
climate::ClimateTraits traits() override { return this->traits_; }
|
||||
|
||||
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
|
||||
void set_temperature_unit(TemperatureUnit unit) {
|
||||
this->traits_.set_temperature_unit(unit);
|
||||
// Visual overrides still apply on top of the converted default range
|
||||
this->traits_.set_visual_min_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MIN_TEMPERATURE, unit));
|
||||
this->traits_.set_visual_max_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MAX_TEMPERATURE, unit));
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core.entity_helpers import entity_temperature_unit_of_id
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -37,7 +38,7 @@ RESTORE_MODES = TemplateWaterHeaterRestoreMode.options(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
water_heater.water_heater_schema(TemplateWaterHeater)
|
||||
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||
@@ -122,8 +123,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_MODE): cv.templatable(
|
||||
water_heater.validate_water_heater_mode
|
||||
),
|
||||
@@ -142,13 +143,16 @@ async def water_heater_template_publish_to_code(
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
|
||||
template_ = await cg.templatable(current_temp, args, cg.float_)
|
||||
cg.add(var.set_current_temperature(template_))
|
||||
|
||||
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
|
||||
template_ = await cg.templatable(target_temp, args, cg.float_)
|
||||
cg.add(var.set_target_temperature(template_))
|
||||
unit = entity_temperature_unit_of_id(config[CONF_ID])
|
||||
for key, setter in (
|
||||
(CONF_CURRENT_TEMPERATURE, var.set_current_temperature),
|
||||
(CONF_TARGET_TEMPERATURE, var.set_target_temperature),
|
||||
):
|
||||
if (value := config.get(key)) is None:
|
||||
continue
|
||||
if isinstance(value, tuple):
|
||||
value = cv.convert_temperature(*value, unit)
|
||||
cg.add(setter(await cg.templatable(value, args, cg.float_)))
|
||||
|
||||
if mode := config.get(CONF_MODE):
|
||||
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
|
||||
|
||||
@@ -32,6 +32,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
|
||||
}
|
||||
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_temperature_unit(this->temperature_unit_);
|
||||
if (this->target_temperature_f_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
||||
|
||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
|
||||
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
|
||||
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
|
||||
this->supported_modes_ = modes;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
||||
TemplateLambda<bool> away_f_;
|
||||
TemplateLambda<bool> is_on_f_;
|
||||
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
|
||||
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
|
||||
water_heater::WaterHeaterModeMask supported_modes_;
|
||||
bool optimistic_{true};
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
FollowEntity,
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
@@ -238,6 +239,12 @@ def sub_text_sensors(
|
||||
return SubEntities(new_text_sensor, config, parent)
|
||||
|
||||
|
||||
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
|
||||
follow_text_sensor = FollowEntity(
|
||||
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
async def to_code(config):
|
||||
cg.add_global(text_sensor_ns.using)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -6,15 +8,19 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
convert_visual_temperatures,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
setup_entity_temperature_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -42,19 +48,36 @@ WATER_HEATER_MODES = WaterHeaterMode.options(
|
||||
)
|
||||
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
|
||||
|
||||
|
||||
_visual_temperatures_in_entity_unit = partial(
|
||||
convert_visual_temperatures, delta_keys=(CONF_TARGET_TEMPERATURE_STEP,)
|
||||
)
|
||||
|
||||
|
||||
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
|
||||
_WATER_HEATER_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
|
||||
|
||||
|
||||
def _final_validate(config: list[ConfigType]) -> None:
|
||||
# Runs once for the whole domain; converts what waited for a platform's unit resolver
|
||||
full_config = fv.full_config.get()
|
||||
for entry in config:
|
||||
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def water_heater_schema(
|
||||
@@ -62,8 +85,13 @@ def water_heater_schema(
|
||||
*,
|
||||
icon: str = cv.UNDEFINED,
|
||||
entity_category: str = cv.UNDEFINED,
|
||||
temperature_unit: bool = False,
|
||||
) -> cv.Schema:
|
||||
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
|
||||
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``."""
|
||||
schema = {cv.GenerateID(): cv.declare_id(class_)}
|
||||
if temperature_unit:
|
||||
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
|
||||
|
||||
for key, default, validator in [
|
||||
(CONF_ICON, icon, cv.icon),
|
||||
@@ -88,6 +116,7 @@ async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> Non
|
||||
if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None:
|
||||
cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES")
|
||||
cg.add(var.set_visual_target_temperature_step_override(temp_step))
|
||||
setup_entity_temperature_unit(var, config)
|
||||
|
||||
|
||||
async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable:
|
||||
|
||||
@@ -218,13 +218,16 @@ WIFI_8266_PHY_MODES = WiFi8266PhyMode.options(
|
||||
)
|
||||
|
||||
WIFI_AP_MODE_FALLBACK = "FALLBACK"
|
||||
WIFI_AP_MODE_ALWAYS_ON = "ALWAYS_ON"
|
||||
WIFI_AP_MODE_WISP = "WISP"
|
||||
|
||||
|
||||
def _validate_ap_mode(value: str) -> str:
|
||||
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
|
||||
if value == WIFI_AP_MODE_WISP:
|
||||
# The bridge is part of the ESP32 backend, on both of its frameworks
|
||||
value = cv.one_of(
|
||||
WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_ALWAYS_ON, WIFI_AP_MODE_WISP, upper=True
|
||||
)(value)
|
||||
if value != WIFI_AP_MODE_FALLBACK:
|
||||
# The AP next to the station is part of the ESP32 backend, on both of its frameworks
|
||||
cv.only_on_esp32(value)
|
||||
return value
|
||||
|
||||
@@ -368,18 +371,21 @@ def wifi_network_ap(value):
|
||||
if value is None:
|
||||
value = {}
|
||||
config = WIFI_NETWORK_AP(value)
|
||||
if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
||||
if not config.get(CONF_PASSWORD):
|
||||
raise cv.Invalid(
|
||||
"WISP mode requires a password for the access point",
|
||||
path=[CONF_PASSWORD],
|
||||
)
|
||||
if config[CONF_AP_TIMEOUT].total_milliseconds == 0:
|
||||
raise cv.Invalid(
|
||||
"ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0",
|
||||
path=[CONF_AP_TIMEOUT],
|
||||
)
|
||||
elif CONF_DNS_SERVERS in config:
|
||||
ap_mode = config[CONF_AP_MODE]
|
||||
if ap_mode == WIFI_AP_MODE_WISP and not config.get(CONF_PASSWORD):
|
||||
raise cv.Invalid(
|
||||
"WISP mode requires a password for the access point",
|
||||
path=[CONF_PASSWORD],
|
||||
)
|
||||
if (
|
||||
ap_mode != WIFI_AP_MODE_FALLBACK
|
||||
and config[CONF_AP_TIMEOUT].total_milliseconds == 0
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"ap_timeout is how long ap_mode: {ap_mode} waits before a failed start is tried again; it cannot be 0",
|
||||
path=[CONF_AP_TIMEOUT],
|
||||
)
|
||||
if ap_mode != WIFI_AP_MODE_WISP and CONF_DNS_SERVERS in config:
|
||||
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
|
||||
if CONF_MANUAL_IP in config and CORE.is_rp2:
|
||||
raise cv.Invalid(
|
||||
@@ -432,6 +438,15 @@ def wifi_has_sta(config: ConfigType) -> bool:
|
||||
return bool(config.get(CONF_NETWORKS))
|
||||
|
||||
|
||||
def _has_upstream(config: ConfigType, full_config: ConfigType) -> bool:
|
||||
"""Whether a station network, ethernet or a modem can carry the AP that stays up."""
|
||||
return (
|
||||
wifi_has_sta(config)
|
||||
or CONF_ETHERNET in full_config
|
||||
or CONF_MODEM in full_config
|
||||
)
|
||||
|
||||
|
||||
# Components that save station credentials at run time
|
||||
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
|
||||
|
||||
@@ -459,24 +474,34 @@ def final_validate(config):
|
||||
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
|
||||
)
|
||||
|
||||
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
|
||||
ap_mode = config[CONF_AP][CONF_AP_MODE] if has_ap else WIFI_AP_MODE_FALLBACK
|
||||
ap_is_wisp = ap_mode == WIFI_AP_MODE_WISP
|
||||
# The AP stays up next to an upstream: the station, or ethernet or modem
|
||||
if ap_mode != WIFI_AP_MODE_FALLBACK and not _has_upstream(config, full_config):
|
||||
raise cv.Invalid(
|
||||
f"ap_mode: {ap_mode} needs a station network (wifi.ssid or wifi.networks), "
|
||||
"or ethernet or modem as the upstream interface"
|
||||
)
|
||||
# The scan after a connect would take the AP down again; with no way for a
|
||||
# network to appear at run time there is nothing for it to roam to.
|
||||
if (
|
||||
ap_mode == WIFI_AP_MODE_ALWAYS_ON
|
||||
and config[CONF_POST_CONNECT_ROAMING]
|
||||
and not wifi_stays_ap_only(config, full_config)
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"ap_mode: ALWAYS_ON is incompatible with post_connect_roaming. "
|
||||
"Please set post_connect_roaming: false in the wifi block."
|
||||
)
|
||||
if ap_is_wisp:
|
||||
# The clients are routed through the station, or through ethernet or modem
|
||||
if (
|
||||
not has_sta
|
||||
and CONF_ETHERNET not in full_config
|
||||
and CONF_MODEM not in full_config
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"WISP mode needs a station network (wifi.ssid or wifi.networks), "
|
||||
"or ethernet or modem as the upstream interface"
|
||||
)
|
||||
if has_captive_portal:
|
||||
raise cv.Invalid(
|
||||
"captive_portal is not supported together with ap_mode: WISP"
|
||||
)
|
||||
# Every client packet passes through the WiFi driver, none through the device's own sockets
|
||||
require_high_performance_wifi()
|
||||
if has_captive_portal and CONF_DNS_SERVERS in config[CONF_AP]:
|
||||
# Clients find the portal through the AP's own address as resolver, which dns_servers replaces
|
||||
raise cv.Invalid(
|
||||
"dns_servers cannot be combined with captive_portal in WISP mode",
|
||||
path=[CONF_AP, CONF_DNS_SERVERS],
|
||||
)
|
||||
if CONF_DNS_SERVERS not in config[CONF_AP]:
|
||||
# The access point passes the upstream's resolvers on, which a static address has to name
|
||||
upstreams = [
|
||||
@@ -845,10 +870,15 @@ async def to_code(config):
|
||||
# Skip the setter when the config matches the C++ initializer.
|
||||
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
|
||||
cg.add(var.set_ap_timeout(ap_timeout))
|
||||
if conf[CONF_AP_MODE] != WIFI_AP_MODE_FALLBACK:
|
||||
cg.add_define("USE_WIFI_AP_PERSISTENT")
|
||||
if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
||||
cg.add_define("USE_WIFI_AP_WISP")
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True)
|
||||
if "captive_portal" in CORE.config:
|
||||
# Starts without a resolver, offering its own address so lookups reach the portal
|
||||
cg.add_define("USE_WIFI_AP_WISP_PORTAL")
|
||||
if dns_servers := conf.get(CONF_DNS_SERVERS):
|
||||
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
|
||||
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
|
||||
|
||||
@@ -404,6 +404,17 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
|
||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
|
||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
|
||||
#endif
|
||||
// Every AP is a provisioning surface except a bridge without captive_portal
|
||||
#if defined(USE_WIFI_AP) && (!defined(USE_WIFI_AP_WISP) || defined(USE_WIFI_AP_WISP_PORTAL))
|
||||
#define WIFI_AP_HAS_PORTAL
|
||||
#endif
|
||||
// On an AP that stays up the portal follows the station: served while it is down, ended when it connects
|
||||
#if defined(USE_WIFI_AP_PERSISTENT) && defined(WIFI_AP_HAS_PORTAL) && \
|
||||
(defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
|
||||
#define WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
/// Keeps a roaming scan or a brief drop from serving the portal again
|
||||
static constexpr uint32_t WIFI_PORTAL_REOPEN_DELAY_MS = 10000;
|
||||
#endif
|
||||
|
||||
/// Cooldown duration when fallback AP is active and captive portal may be running
|
||||
/// Longer interval gives users time to configure WiFi without constant connection attempts
|
||||
@@ -657,18 +668,19 @@ void WiFiComponent::setup() {
|
||||
this->configured_power_save_ = this->power_save_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || defined(WIFI_AP_HAS_PORTAL))
|
||||
// The access point, the portals on it and the DPP listen are provisioning surfaces: once the
|
||||
// window has closed, end them (mirrors the teardown on a successful connection). The blocks in
|
||||
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
|
||||
// carries no portal and stays up.
|
||||
// loop() that start them are gated so none of them is started again afterwards. An AP that
|
||||
// stays up only loses its portal.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
#ifdef USE_WIFI_DPP
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
|
||||
#ifndef USE_WIFI_AP_PERSISTENT
|
||||
if (this->ap_setup_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
@@ -681,7 +693,8 @@ void WiFiComponent::setup() {
|
||||
#endif
|
||||
this->disable_ap_();
|
||||
}
|
||||
#endif // USE_WIFI_AP
|
||||
#endif // !USE_WIFI_AP_PERSISTENT
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
});
|
||||
}
|
||||
#endif
|
||||
@@ -783,18 +796,12 @@ void WiFiComponent::start() {
|
||||
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
|
||||
this->start_initial_connection_();
|
||||
#endif
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
} else if (this->has_ap()) { // a bridge without a portal waits in loop() for the upstream's resolver
|
||||
this->setup_ap_config_();
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
this->start_portal_scan_();
|
||||
this->start_ap_portal_();
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
}
|
||||
#ifdef USE_WIFI_DPP
|
||||
// Without networks there is nothing to try first, so listen for a configurator right away
|
||||
@@ -934,7 +941,7 @@ void WiFiComponent::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_PERSISTENT)
|
||||
// Once the provisioning window has closed, don't bring up the fallback AP (or
|
||||
// the captive portal on it) - the device must stay unprovisionable until it is
|
||||
// power-cycled.
|
||||
@@ -964,7 +971,13 @@ void WiFiComponent::loop() {
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
}
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_PERSISTENT
|
||||
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
if (this->ap_setup_ && !this->is_connected() && now - this->last_connected_ > WIFI_PORTAL_REOPEN_DELAY_MS &&
|
||||
!this->is_provisioning_closed_() && !this->is_ap_portal_active_()) {
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IMPROV_BLE
|
||||
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
|
||||
@@ -1002,13 +1015,24 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
|
||||
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
// The AP stays up next to the upstream, so it is started with any upstream; a start that failed is
|
||||
// tried again after ap_timeout. A bridge without a portal waits for a resolver to offer.
|
||||
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
|
||||
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_PORTAL)
|
||||
this->ap_dns_known_() &&
|
||||
#endif
|
||||
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
|
||||
ESP_LOGI(TAG, "Starting AP");
|
||||
this->ap_changed_ = now;
|
||||
this->setup_ap_config_();
|
||||
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
// With a station the follows-station block serves the portal after its grace period
|
||||
if (!this->has_sta() && !this->is_provisioning_closed_()) {
|
||||
this->start_portal_scan_(); // as in start(), so credentials saved through the portal are tried
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1102,9 +1126,9 @@ void WiFiComponent::setup_ap_config_() {
|
||||
!this->wifi_apply_output_power_(this->output_power_)) {
|
||||
ESP_LOGV(TAG, "Setting Output Power Option failed");
|
||||
}
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
|
||||
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
|
||||
// the STA attempts until the next try, and an AP that stays up would take clients it cannot serve.
|
||||
if (!this->ap_setup_)
|
||||
this->wifi_mode_({}, false);
|
||||
#endif
|
||||
@@ -1153,24 +1177,23 @@ void WiFiComponent::pause_exclusive_ap_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
void WiFiComponent::fail_bridge_() {
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
void WiFiComponent::forget_ap_() {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
this->ap_offers_own_dns_ = false; // the AP goes down, so ending its portal rewrites nothing
|
||||
this->cancel_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID); // the next AP setup works out its clients' resolver itself
|
||||
#endif
|
||||
|
||||
void WiFiComponent::disable_ap_() {
|
||||
this->end_ap_portal_();
|
||||
ESP_LOGD(TAG, "Disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
// The fallback block may bring the AP back after a later disconnect; its clients dropped with it without
|
||||
// a disconnect event for each
|
||||
// The AP may come back later; its clients dropped with it without a disconnect event for each
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
}
|
||||
|
||||
void WiFiComponent::disable_ap_() {
|
||||
this->forget_ap_();
|
||||
ESP_LOGD(TAG, "Disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
|
||||
void WiFiComponent::set_ap(const WiFiAP &ap) {
|
||||
this->ap_ = ap;
|
||||
this->has_ap_ = true;
|
||||
@@ -1503,14 +1526,12 @@ void WiFiComponent::disable() {
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
this->wifi_disconnect_();
|
||||
this->wifi_mode_(false, false);
|
||||
#ifdef USE_WIFI_AP
|
||||
// The AP went down with the radio; enable() has to configure it, and its portal, again
|
||||
this->end_ap_portal_();
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
// The AP goes down with the radio; enable() has to configure it, and its portal, again
|
||||
this->forget_ap_();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
this->wifi_mode_(false, false);
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
this->ap_changed_ = 0; // at once, not after ap_timeout
|
||||
#endif
|
||||
}
|
||||
@@ -1809,9 +1830,13 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
// Reset to initial phase on successful connection (don't log transition, just reset state)
|
||||
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
|
||||
this->num_retried_ = 0;
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
if (this->has_ap()) {
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
this->end_ap_portal_(); // the AP stays up for its clients
|
||||
#else
|
||||
this->disable_ap_();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_WIFI_DPP
|
||||
@@ -2452,8 +2477,23 @@ void WiFiComponent::start_ap_portal_() {
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->start_captive();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
this->apply_ap_dns_(); // point the clients at the portal; a failed refresh takes the AP down for loop() to retry
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
void WiFiComponent::start_portal_scan_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::end_ap_portal_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
if (this->is_captive_portal_active_())
|
||||
@@ -2462,6 +2502,10 @@ void WiFiComponent::end_ap_portal_() {
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->end_captive();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (this->ap_setup_ && this->ap_offers_own_dns_)
|
||||
this->apply_ap_dns_(); // hand the clients the upstream's resolver again
|
||||
#endif
|
||||
}
|
||||
bool WiFiComponent::is_improv_ble_active_() {
|
||||
#ifdef USE_IMPROV_BLE
|
||||
|
||||
@@ -460,6 +460,7 @@ class WiFiPowerSaveListener {
|
||||
// Scheduler ids
|
||||
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
|
||||
static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1;
|
||||
static constexpr uint32_t AP_DEAUTH_RETRY_TIMEOUT_ID = 2;
|
||||
|
||||
/// This component is responsible for managing the ESP WiFi interface.
|
||||
class WiFiComponent final : public Component {
|
||||
@@ -739,6 +740,8 @@ class WiFiComponent final : public Component {
|
||||
void setup_ap_config_();
|
||||
/// End the portals and turn the AP off.
|
||||
void disable_ap_();
|
||||
/// End the portals and forget the AP's state, before the radio takes the AP down.
|
||||
void forget_ap_();
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
|
||||
void pause_exclusive_ap_();
|
||||
@@ -750,11 +753,16 @@ class WiFiComponent final : public Component {
|
||||
bool ap_dns_known_();
|
||||
/// Hand the AP DHCP server its resolvers; false when one could not be set
|
||||
bool write_ap_dns_();
|
||||
/// Take the bridge down so loop() runs its setup again after ap_timeout
|
||||
void fail_bridge_();
|
||||
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
/// Pass the station's resolvers on to AP clients when they changed
|
||||
void apply_ap_dns_();
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
/// Disconnect the AP clients so they rejoin with the resolver now offered; a few retries, then the AP restarts
|
||||
void drop_ap_clients_(uint8_t attempt = 0);
|
||||
/// DHCP offers the AP's own address while the portal is up for a station that is down, or while the
|
||||
/// upstream has no resolver, so lookups reach the portal
|
||||
bool offer_own_dns_();
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -883,6 +891,10 @@ class WiFiComponent final : public Component {
|
||||
bool is_ap_portal_active_();
|
||||
void start_ap_portal_();
|
||||
void end_ap_portal_();
|
||||
#ifdef USE_WIFI_AP
|
||||
/// Scan alongside the AP so the portal can list networks, when there is no station to try
|
||||
void start_portal_scan_();
|
||||
#endif
|
||||
bool is_improv_ble_active_();
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
@@ -1030,7 +1042,7 @@ class WiFiComponent final : public Component {
|
||||
float output_power_{NAN};
|
||||
uint32_t action_started_;
|
||||
uint32_t last_connected_{0};
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||
uint32_t ap_changed_{0}; // When the AP was last started or paused
|
||||
#endif
|
||||
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
|
||||
@@ -1149,6 +1161,10 @@ class WiFiComponent final : public Component {
|
||||
// Fallback AP starts that failed since the last connection; each one waits another ap_timeout
|
||||
uint8_t ap_start_failures_{0};
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
// DHCP offers the AP's own address; apply_ap_dns_() drops the clients whenever that changes
|
||||
bool ap_offers_own_dns_{false};
|
||||
#endif
|
||||
bool passive_scan_{false};
|
||||
bool has_saved_wifi_settings_{false};
|
||||
|
||||
@@ -1263,7 +1263,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
// the AP goes down and its setup runs again, which brings it back up through this event.
|
||||
if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err));
|
||||
this->fail_bridge_();
|
||||
this->disable_ap_();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1471,8 +1471,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
// Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would
|
||||
// offer the AP's own address, so a failure fails the setup and it is tried again.
|
||||
// Offer resolvers through DHCP: the configured ones, otherwise the station's (with a portal, the AP's own
|
||||
// address while there is none). A failure fails the setup and it is tried again.
|
||||
uint8_t dns_offer = 1;
|
||||
err =
|
||||
esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer));
|
||||
@@ -1652,7 +1652,7 @@ static constexpr uint32_t AP_DNS[] = {
|
||||
};
|
||||
#endif
|
||||
|
||||
static uint32_t ap_dns_to_offer(size_t i) {
|
||||
static uint32_t upstream_dns(size_t i) {
|
||||
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
|
||||
#else
|
||||
@@ -1660,14 +1660,45 @@ static uint32_t ap_dns_to_offer(size_t i) {
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
|
||||
static uint32_t ap_dns_to_offer(size_t i, [[maybe_unused]] bool own) {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (own) {
|
||||
esp_netif_ip_info_t ip{};
|
||||
if (s_ap_netif != nullptr && esp_netif_get_ip_info(s_ap_netif, &ip) == ESP_OK)
|
||||
return ip.ip.addr;
|
||||
ESP_LOGW(TAG, "Failed to read the AP address to offer as resolver");
|
||||
return 0;
|
||||
}
|
||||
// A slot the upstream leaves empty repeats the primary, so the AP's own address is replaced there too
|
||||
const uint32_t addr = upstream_dns(i);
|
||||
return addr != 0 ? addr : upstream_dns(0);
|
||||
#else
|
||||
return upstream_dns(i);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WiFiComponent::ap_dns_known_() { return upstream_dns(0) != 0; }
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
bool WiFiComponent::offer_own_dns_() {
|
||||
return !this->ap_dns_known_() || (this->has_sta() && this->is_ap_portal_active_());
|
||||
}
|
||||
#endif
|
||||
|
||||
bool WiFiComponent::write_ap_dns_() {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
const bool own = this->offer_own_dns_();
|
||||
this->ap_offers_own_dns_ = own;
|
||||
#else
|
||||
const bool own = false;
|
||||
#endif
|
||||
bool ok = true;
|
||||
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
if (addr == 0)
|
||||
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||
if (addr == 0) {
|
||||
ok = ok && !own; // the AP's own address could not be read
|
||||
continue;
|
||||
}
|
||||
esp_netif_dns_info_t info{};
|
||||
info.ip.type = ESP_IPADDR_TYPE_V4;
|
||||
info.ip.u_addr.ip4.addr = addr;
|
||||
@@ -1685,28 +1716,65 @@ bool WiFiComponent::write_ap_dns_() {
|
||||
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
void WiFiComponent::apply_ap_dns_() {
|
||||
if (!this->ap_setup_)
|
||||
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
|
||||
return; // loop() brings the AP up, and its setup writes the resolvers
|
||||
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
|
||||
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
|
||||
// next lease. A resolver the station lost stays offered (set_dns_info rejects an empty address), or with a
|
||||
// portal gives way to the AP's own address.
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
const bool own = this->offer_own_dns_();
|
||||
#else
|
||||
const bool own = false;
|
||||
#endif
|
||||
bool changed = false;
|
||||
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||
esp_netif_dns_info_t current{};
|
||||
// A missing own address goes through the setup too, which then fails and restarts the AP
|
||||
changed =
|
||||
addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), ¤t) != ESP_OK ||
|
||||
current.ip.u_addr.ip4.addr != addr);
|
||||
(addr == 0 && own) ||
|
||||
(addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), ¤t) != ESP_OK ||
|
||||
current.ip.u_addr.ip4.addr != addr));
|
||||
}
|
||||
if (!changed)
|
||||
return;
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
// Clients rejoin whenever the offer switches between the AP's own address and the upstream's resolver
|
||||
const bool offered_own = this->ap_offers_own_dns_;
|
||||
#endif
|
||||
// The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
|
||||
#else
|
||||
const bool ok = this->wifi_ap_ip_config_({});
|
||||
#endif
|
||||
if (!ok)
|
||||
this->fail_bridge_();
|
||||
if (!ok) {
|
||||
this->disable_ap_();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (offered_own != this->ap_offers_own_dns_)
|
||||
this->drop_ap_clients_();
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
void WiFiComponent::drop_ap_clients_(uint8_t attempt) {
|
||||
static constexpr uint8_t MAX_ATTEMPTS = 5;
|
||||
if (!this->ap_setup_)
|
||||
return;
|
||||
esp_err_t err = esp_wifi_deauth_sta(0);
|
||||
if (err == ESP_OK)
|
||||
return;
|
||||
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; restarting AP",
|
||||
esp_err_to_name(err));
|
||||
this->disable_ap_(); // loop() starts it again, and the clients rejoin
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; retrying", esp_err_to_name(err));
|
||||
this->set_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID, 1000, [this, attempt]() { this->drop_ap_clients_(attempt + 1); });
|
||||
}
|
||||
#endif
|
||||
#endif // USE_WIFI_AP_WISP_STATIC_DNS
|
||||
#endif // USE_WIFI_AP_WISP
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ _CALLBACK_AUTOMATIONS = (
|
||||
)
|
||||
|
||||
|
||||
# Above safe_mode, so Bluetooth OTA can still recover a device stuck in a boot loop
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
zephyr_add_prj_conf("BT", True)
|
||||
@@ -93,6 +95,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
zephyr_add_prj_conf("BT_SMP_SC_ONLY", True)
|
||||
zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True)
|
||||
request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum
|
||||
# Pairing confirmation is the user's automation, so in safe mode new pairings
|
||||
# are rejected (ble_server.cpp) and only bonded hosts can connect
|
||||
await cg.past_safe_mode()
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -181,7 +181,14 @@ void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) {
|
||||
snprintk(passkey_str, 7, "%06u", passkey);
|
||||
|
||||
ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str);
|
||||
global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); });
|
||||
global_ble_server->defer([passkey]() {
|
||||
if (global_ble_server->passkey_cb_.size() == 0) {
|
||||
ESP_LOGW(TAG, "Nothing answers numeric comparison (safe mode skips the automation), rejecting pairing");
|
||||
global_ble_server->numeric_comparison_reply(false);
|
||||
return;
|
||||
}
|
||||
global_ble_server->passkey_cb_(passkey);
|
||||
});
|
||||
}
|
||||
|
||||
static void auth_pairing_confirm(bt_conn *conn) {
|
||||
|
||||
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
class OptionalPinValidationStep(ConfigValidationStep):
|
||||
"""Remove optional pins that are also used elsewhere"""
|
||||
|
||||
# After the ID pass, and before component final validations which may inspect pin use
|
||||
priority = -15.0
|
||||
|
||||
def run(self, result: Config) -> None:
|
||||
if result.errors:
|
||||
return
|
||||
pins.PIN_SCHEMA_REGISTRY.remove_optional_conflicts(result)
|
||||
|
||||
|
||||
class PinUseValidationCheck(ConfigValidationStep):
|
||||
"""Check for pin reuse"""
|
||||
|
||||
@@ -1437,6 +1449,7 @@ def validate_config(
|
||||
result.add_validation_step(PrefetchRemoteFilesValidationStep())
|
||||
result.add_validation_step(IDPassValidationStep())
|
||||
result.add_validation_step(CoreFinalValidateStep())
|
||||
result.add_validation_step(OptionalPinValidationStep())
|
||||
result.add_validation_step(PinUseValidationCheck())
|
||||
|
||||
result.add_validation_step(RemoveReferenceValidationStep())
|
||||
|
||||
@@ -78,6 +78,9 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
@@ -86,6 +89,7 @@ from esphome.core import (
|
||||
CORE,
|
||||
HexInt,
|
||||
Lambda,
|
||||
TemperatureWithUnit,
|
||||
TimePeriod,
|
||||
TimePeriodMicroseconds,
|
||||
TimePeriodMilliseconds,
|
||||
@@ -94,6 +98,7 @@ from esphome.core import (
|
||||
TimePeriodSeconds,
|
||||
Version,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||
from esphome.helpers import (
|
||||
@@ -1520,7 +1525,7 @@ METRIC_SUFFIXES = {
|
||||
|
||||
def float_with_unit(quantity, regex_suffix, optional_unit=False):
|
||||
pattern = re.compile(
|
||||
f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
f"^([-+]?(?:[0-9]+\\.?[0-9]*|\\.[0-9]+))\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
)
|
||||
|
||||
def validator(value):
|
||||
@@ -1557,53 +1562,73 @@ distance = float_with_unit("distance", "(m)")
|
||||
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
|
||||
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
|
||||
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
|
||||
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
|
||||
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
|
||||
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
|
||||
_temperature_k = float_with_unit("temperature", "(°K|° K|K)")
|
||||
_temperature_f = float_with_unit("temperature", "(°F|° F|F)")
|
||||
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
|
||||
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
|
||||
|
||||
|
||||
def temperature(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
class TemperatureUnitValue(str):
|
||||
"""A unit from temperature_unit(): the platform took the option, so the entity reports in it."""
|
||||
|
||||
try:
|
||||
kelvin = _temperature_k(value)
|
||||
return kelvin - 273.15
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
try:
|
||||
fahrenheit = _temperature_f(value)
|
||||
return (fahrenheit - 32) * (5 / 9)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
raise err
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
def temperature_delta(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
def temperature_unit(value: Any) -> TemperatureUnitValue:
|
||||
"""Validate the unit an entity reports its temperatures in."""
|
||||
return TemperatureUnitValue(one_of(*TEMPERATURE_UNITS)(value))
|
||||
|
||||
try:
|
||||
return _temperature_k(value)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
try:
|
||||
fahrenheit = _temperature_f(value)
|
||||
return fahrenheit * (5 / 9)
|
||||
except Invalid:
|
||||
pass
|
||||
def temperature_with_unit(value: Any) -> TemperatureWithUnit:
|
||||
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
|
||||
for validator, unit in (
|
||||
(_temperature_k, UNIT_KELVIN),
|
||||
(_temperature_f, UNIT_FAHRENHEIT),
|
||||
(_temperature_c_strict, UNIT_CELSIUS),
|
||||
):
|
||||
try:
|
||||
return TemperatureWithUnit(validator(value), unit)
|
||||
except Invalid:
|
||||
pass
|
||||
return TemperatureWithUnit(_temperature_c(value), None)
|
||||
|
||||
raise err
|
||||
|
||||
# unit = celsius * scale + offset
|
||||
_TEMPERATURE_SCALES = {
|
||||
UNIT_CELSIUS: (1.0, 0.0),
|
||||
UNIT_FAHRENHEIT: (9 / 5, 32.0),
|
||||
UNIT_KELVIN: (1.0, 273.15),
|
||||
}
|
||||
|
||||
|
||||
templatable_temperature = templatable(temperature_with_unit)
|
||||
|
||||
|
||||
def convert_temperature(
|
||||
value: float, unit: str | None, to_unit: str, *, delta: bool = False
|
||||
) -> float:
|
||||
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
|
||||
|
||||
A value written without a unit is taken to be in ``to_unit`` already.
|
||||
"""
|
||||
if unit is None or unit == to_unit:
|
||||
return value
|
||||
scale, offset = _TEMPERATURE_SCALES[unit]
|
||||
to_scale, to_offset = _TEMPERATURE_SCALES[to_unit]
|
||||
if delta:
|
||||
return value / scale * to_scale
|
||||
return (value - offset) / scale * to_scale + to_offset
|
||||
|
||||
|
||||
def temperature(value: Any) -> float:
|
||||
"""Parse a temperature in °C, K or °F into degrees Celsius."""
|
||||
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
|
||||
|
||||
|
||||
def temperature_delta(value: Any) -> float:
|
||||
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
|
||||
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
|
||||
|
||||
|
||||
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
|
||||
|
||||
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user