mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 15:35:34 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472a723b53 | ||
|
|
4d734478e4 | ||
|
|
440c0d9cb5 | ||
|
|
59e36171f2 | ||
|
|
877fb27a07 | ||
|
|
3b7926776d | ||
|
|
f95bc94f94 | ||
|
|
6f9aa52cd2 | ||
|
|
712e977ce9 | ||
|
|
3e068d7e2e | ||
|
|
5546dc3e40 | ||
|
|
e778e9eaf0 | ||
|
|
d4b745be13 |
No files matched your search
@@ -16,8 +16,6 @@ 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
|
||||
@@ -88,7 +86,6 @@ 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,9 +348,8 @@ 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, 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.
|
||||
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.
|
||||
"""
|
||||
|
||||
conf_key: str | tuple[str, ...]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DOMAIN = "adafruit_ble"
|
||||
@@ -1,603 +0,0 @@
|
||||
"""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
|
||||
@@ -1,83 +0,0 @@
|
||||
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)
|
||||
@@ -1,73 +0,0 @@
|
||||
#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
|
||||
@@ -1,19 +0,0 @@
|
||||
#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
|
||||
@@ -62,6 +62,13 @@ struct SpiData {
|
||||
|
||||
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// BDK 3.0.78 makes REG_WRITE a statement expression that yields the volatile assignment,
|
||||
// a deprecated volatile read under clang
|
||||
static inline void reg_write(uint32_t addr, uint32_t value) {
|
||||
// NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address
|
||||
*reinterpret_cast<volatile uint32_t *>(addr) = value;
|
||||
}
|
||||
|
||||
static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CTRL);
|
||||
if (val == 0) {
|
||||
@@ -69,7 +76,7 @@ static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
} else if (val == 1) {
|
||||
value |= bit;
|
||||
}
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
reg_write(SPI_CTRL, value);
|
||||
}
|
||||
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
@@ -80,7 +87,7 @@ static void set_spi_config_register(uint32_t bit, bool val) {
|
||||
} else if (val == 1) {
|
||||
value |= bit;
|
||||
}
|
||||
REG_WRITE(SPI_CONFIG, value);
|
||||
reg_write(SPI_CONFIG, value);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -136,7 +143,7 @@ static void spi_set_clock(uint32_t max_hz) {
|
||||
param = REG_READ(SPI_CTRL);
|
||||
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
|
||||
param |= (div << SPI_CKR_POSI);
|
||||
REG_WRITE(SPI_CTRL, param);
|
||||
reg_write(SPI_CTRL, param);
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
const int divisor = div;
|
||||
#else
|
||||
@@ -229,7 +236,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
value = REG_READ(SPI_CTRL);
|
||||
value &= ~CTRL_NSSMD_3;
|
||||
value |= (1 << 17);
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
reg_write(SPI_CTRL, value);
|
||||
|
||||
#ifdef BEKEN_SPI_IS_N_LIKE
|
||||
value = GFUNC_MODE_SPI_DMA;
|
||||
@@ -279,7 +286,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
value = REG_READ(SPI_CONFIG);
|
||||
value &= ~(0xFFF << 8);
|
||||
value |= ((dma_buffer_size & 0xFFF) << 8);
|
||||
REG_WRITE(SPI_CONFIG, value);
|
||||
reg_write(SPI_CONFIG, value);
|
||||
#else
|
||||
// T-like variants: No transfer-length register. The SPI controller shifts out
|
||||
// everything the DMA writes into SPI_DAT.
|
||||
|
||||
@@ -21,9 +21,10 @@ public ble_api.h.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny, wifi
|
||||
from esphome.components import libretiny
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
@@ -32,14 +33,30 @@ from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7251,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
CONF_ESPHOME,
|
||||
CONF_ID,
|
||||
CONF_PLATFORMIO_OPTIONS,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
DOMAIN = "bk72xx_ble"
|
||||
|
||||
BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
|
||||
# Older BDKs lay the BLE stack out differently and can crash at boot or erase the
|
||||
# bootloader with BLE compiled in; the libretiny platform builds with this one
|
||||
MIN_BDK_VERSION = (3, 0, 78)
|
||||
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
|
||||
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
|
||||
MIN_LIBRETINY_VERSION = (1, 14, 0)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
|
||||
@@ -80,20 +97,45 @@ def _unsupported_family_message(family: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def check_bdk_version(value: str | None) -> None:
|
||||
"""Reject a beken-bdk override older than the release the BLE stack needs."""
|
||||
if value is None:
|
||||
return
|
||||
# LibreTiny appends the value to the package URL as a git ref and only builds
|
||||
# 3.0.x releases; the 2021.06.07 tag is the pre 3.0 SDK
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", str(value))
|
||||
if match is not None:
|
||||
version = tuple(int(g) for g in match.groups())
|
||||
if version[:2] == MIN_BDK_VERSION[:2] and version >= MIN_BDK_VERSION:
|
||||
return
|
||||
minimum = ".".join(str(part) for part in MIN_BDK_VERSION)
|
||||
raise cv.Invalid(
|
||||
f"bk72xx_ble needs beken-bdk {minimum} or a newer 3.0.x release, not "
|
||||
f"'{value}': with BLE compiled in, an older BDK can crash at boot or erase "
|
||||
"the bootloader"
|
||||
)
|
||||
|
||||
|
||||
def check_libretiny_version(version: cv.Version) -> None:
|
||||
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
|
||||
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
|
||||
return
|
||||
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
|
||||
raise cv.Invalid(
|
||||
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
|
||||
"arm the Beken MCU sleep with wifi power save and the device halts once the "
|
||||
"station stops while BLE runs"
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# Warn only: a hard error here would break the validate-only CI fixtures,
|
||||
# which run on a BLE 4.2 board. The hard error is raised at codegen.
|
||||
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
|
||||
_LOGGER.warning("%s (this configuration cannot compile)", msg)
|
||||
# Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU
|
||||
# sleep. With the BLE controller running, that sleep never wakes up once the
|
||||
# station is stopped (adapter restart after failed roams, wifi.disable): the
|
||||
# device is dead until a power cycle (esphome#18592). Keep power save off
|
||||
# until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414).
|
||||
wifi.force_power_save_off(
|
||||
"with BLE running, the Beken SDK's MCU sleep halts the device once the "
|
||||
"station is stopped (https://github.com/esphome/esphome/issues/18592)"
|
||||
)
|
||||
options = fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) or {}
|
||||
check_bdk_version(options.get(BEKEN_BDK_VERSION_OPTION))
|
||||
check_libretiny_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -116,20 +158,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
|
||||
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
|
||||
|
||||
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
|
||||
# bundled 3.0.33 has an older BLE header/library layout — and with
|
||||
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
|
||||
# (the reason the libretiny platform sets =0 when BLE is unused), so a
|
||||
# mismatched BDK can crash the device before WiFi comes up regardless of
|
||||
# enable_on_boot. Pinning here makes a plain config build against the
|
||||
# validated BDK without any manual platformio_options.
|
||||
_LOGGER.warning(
|
||||
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
|
||||
"default: the default's older BLE layout can crash the device at boot when "
|
||||
"BLE is compiled in"
|
||||
)
|
||||
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
|
||||
|
||||
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
|
||||
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
|
||||
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
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()"
|
||||
)
|
||||
@@ -1,310 +0,0 @@
|
||||
#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
|
||||
@@ -1,138 +0,0 @@
|
||||
#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
|
||||
@@ -1,198 +0,0 @@
|
||||
#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
|
||||
@@ -1,131 +0,0 @@
|
||||
#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,4 +1,3 @@
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -47,24 +46,16 @@ 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
|
||||
@@ -147,33 +138,30 @@ 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): cv.temperature_with_unit,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 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 = 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,
|
||||
)
|
||||
value = visual_temperature(value)
|
||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(
|
||||
{
|
||||
CONF_TARGET_TEMPERATURE: value,
|
||||
CONF_CURRENT_TEMPERATURE: value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA = (
|
||||
@@ -184,8 +172,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_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
|
||||
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
|
||||
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
|
||||
@@ -262,24 +250,6 @@ _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(
|
||||
@@ -287,26 +257,10 @@ 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),
|
||||
@@ -351,7 +305,6 @@ 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)
|
||||
@@ -526,9 +479,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_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||
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_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||
@@ -544,23 +497,17 @@ 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),
|
||||
*TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
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_
|
||||
),
|
||||
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
||||
automation.ApplyField(
|
||||
|
||||
@@ -77,10 +77,6 @@ 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();
|
||||
@@ -197,8 +193,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_{DEFAULT_VISUAL_MIN_TEMPERATURE};
|
||||
float visual_max_temperature_{DEFAULT_VISUAL_MAX_TEMPERATURE};
|
||||
float visual_min_temperature_{10};
|
||||
float visual_max_temperature_{30};
|
||||
float visual_target_temperature_step_{0.1};
|
||||
float visual_current_temperature_step_{0.1};
|
||||
float visual_min_humidity_{30};
|
||||
|
||||
@@ -33,11 +33,9 @@ ClimateIR = climate_ir_ns.class_(
|
||||
|
||||
def climate_ir_schema(
|
||||
class_: MockObjClass,
|
||||
*,
|
||||
fixed_temperature_unit: str | None = None,
|
||||
) -> cv.Schema:
|
||||
return (
|
||||
climate.climate_schema(class_, fixed_temperature_unit=fixed_temperature_unit)
|
||||
climate.climate_schema(class_)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
|
||||
@@ -54,12 +52,8 @@ 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_, fixed_temperature_unit=fixed_temperature_unit
|
||||
).extend(
|
||||
return climate_ir_schema(class_).extend(
|
||||
{
|
||||
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
|
||||
remote_base.RemoteReceiverBase
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,7 +4,6 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -55,8 +54,6 @@ 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",
|
||||
@@ -106,13 +103,6 @@ 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 = {
|
||||
@@ -458,16 +448,15 @@ 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)
|
||||
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);
|
||||
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];
|
||||
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.0f + minutes + seconds / 60.0f;
|
||||
float charging_minutes = hours * 60 + minutes;
|
||||
|
||||
if (this->power_sensor_ != nullptr)
|
||||
this->power_sensor_->publish_state(power_kw);
|
||||
@@ -482,13 +471,11 @@ 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)
|
||||
// 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]);
|
||||
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);
|
||||
}
|
||||
|
||||
// Poll the error code register every cycle
|
||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||
@@ -501,15 +488,8 @@ 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) {
|
||||
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.");
|
||||
}
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -523,7 +503,6 @@ 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();
|
||||
@@ -554,35 +533,6 @@ 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,7 +5,6 @@
|
||||
#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"
|
||||
@@ -57,14 +56,12 @@ 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();
|
||||
|
||||
@@ -98,7 +95,6 @@ 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_{};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core, pins
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import (
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
@@ -56,33 +54,6 @@ 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),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
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,5 +128,24 @@ class EpaperModel:
|
||||
return model
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""Check that the components this model needs are configured, see check_model_requirements()."""
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
"""
|
||||
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"
|
||||
)
|
||||
@@ -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, UNIT_FAHRENHEIT
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -13,10 +13,7 @@ FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
|
||||
Model = friedrich_ns.enum("Model", is_class=True)
|
||||
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
|
||||
|
||||
# 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(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).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
|
||||
|
||||
@@ -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, UNIT_FAHRENHEIT
|
||||
from esphome.const import CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -11,10 +11,7 @@ CODEOWNERS = ["@ipopov"]
|
||||
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
||||
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
||||
|
||||
# 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(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
|
||||
{
|
||||
# The unit has no auto (heat/cool) mode
|
||||
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
||||
|
||||
@@ -274,7 +274,9 @@ 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.
|
||||
**display.enable_pin_schema(model, config),
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
|
||||
): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
cv.Optional(CONF_RESET_DURATION): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
||||
|
||||
@@ -40,6 +40,7 @@ from .const import (
|
||||
CONF_UART_PORT,
|
||||
FAMILIES,
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_COMPONENT,
|
||||
FAMILY_FRIENDLY,
|
||||
@@ -233,14 +234,14 @@ def _notify_old_style(config):
|
||||
# The dev and latest branches will be at *least* this version, which is what matters.
|
||||
# Use GitHub releases directly to avoid PlatformIO moderation delays.
|
||||
ARDUINO_VERSIONS = {
|
||||
"dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"),
|
||||
"dev": (cv.Version(1, 14, 0), "https://github.com/libretiny-eu/libretiny.git"),
|
||||
"latest": (
|
||||
cv.Version(1, 13, 0),
|
||||
"https://github.com/libretiny-eu/libretiny.git#v1.13.0",
|
||||
cv.Version(1, 14, 0),
|
||||
"https://github.com/libretiny-eu/libretiny.git#v1.14.0",
|
||||
),
|
||||
"recommended": (
|
||||
cv.Version(1, 13, 0),
|
||||
"https://github.com/libretiny-eu/libretiny.git#v1.13.0",
|
||||
cv.Version(1, 14, 0),
|
||||
"https://github.com/libretiny-eu/libretiny.git#v1.14.0",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -516,6 +517,13 @@ async def component_to_code(config):
|
||||
if config[CONF_FAMILY] == FAMILY_RTL8710B:
|
||||
cg.add_platformio_option("custom_versions.freertos", "8.2.3")
|
||||
|
||||
# LibreTiny bundles BDK 3.0.33 for these two but already builds BK7238 with 3.0.78,
|
||||
# where STA+AP works (esphome#19430), the web server OTA does not trip the watchdog
|
||||
# (esphome#19429) and the no WiFi reboot loop is gone (esphome#18268). A
|
||||
# platformio_options custom_versions.beken-bdk entry in the config still wins.
|
||||
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7231T):
|
||||
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
|
||||
|
||||
# force using arduino framework
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
@@ -533,8 +541,10 @@ async def component_to_code(config):
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
# Must run before the platform's builder scripts are loaded; see the script.
|
||||
cg.add_platformio_option("extra_scripts", ["pre:scons_dont_inherit.py"])
|
||||
# LibreTiny before 1.14.0 breaks under SCons 4.11 without this script; see the script
|
||||
version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
if (version.major, version.minor, version.patch) < (1, 14, 0):
|
||||
cg.add_platformio_option("extra_scripts", ["pre:scons_dont_inherit.py"])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||
# On other families, patch_linker.py routes .sram.text into the right
|
||||
|
||||
@@ -42,11 +42,14 @@ from esphome.components.libretiny.const import (
|
||||
LibreTinyComponent,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
{IMPORTS}
|
||||
|
||||
CODEOWNERS = {CODEOWNERS}
|
||||
AUTO_LOAD = ["libretiny"]
|
||||
DOMAIN = "{COMPONENT_LOWER}"
|
||||
IS_TARGET_PLATFORM = True
|
||||
|
||||
COMPONENT_DATA = LibreTinyComponent(
|
||||
@@ -59,7 +62,7 @@ COMPONENT_DATA = LibreTinyComponent(
|
||||
)
|
||||
|
||||
|
||||
def _set_core_data(config):
|
||||
def _set_core_data(config: ConfigType) -> ConfigType:
|
||||
CORE.data[KEY_LIBRETINY] = {}
|
||||
CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] = COMPONENT_DATA
|
||||
return config
|
||||
@@ -76,12 +79,12 @@ PIN_SCHEMA = {PIN_SCHEMA}
|
||||
CONFIG_SCHEMA.prepend_extra(_set_core_data)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> MockObj:
|
||||
return await libretiny.component_to_code(config)
|
||||
|
||||
|
||||
@pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA)
|
||||
async def pin_to_code(config):
|
||||
async def pin_to_code(config: ConfigType) -> MockObj:
|
||||
return await libretiny.gpio.component_pin_to_code(config)
|
||||
|
||||
|
||||
|
||||
@@ -355,11 +355,12 @@ LN882X_BOARD_PINS = {
|
||||
"WIRE0_SCL_5": 5,
|
||||
"WIRE0_SCL_6": 6,
|
||||
"WIRE0_SCL_7": 9,
|
||||
"WIRE0_SCL_8": 11,
|
||||
"WIRE0_SCL_9": 20,
|
||||
"WIRE0_SCL_10": 21,
|
||||
"WIRE0_SCL_11": 22,
|
||||
"WIRE0_SCL_12": 25,
|
||||
"WIRE0_SCL_8": 10,
|
||||
"WIRE0_SCL_9": 11,
|
||||
"WIRE0_SCL_10": 20,
|
||||
"WIRE0_SCL_11": 21,
|
||||
"WIRE0_SCL_12": 22,
|
||||
"WIRE0_SCL_13": 25,
|
||||
"WIRE0_SDA_0": 0,
|
||||
"WIRE0_SDA_1": 1,
|
||||
"WIRE0_SDA_2": 2,
|
||||
@@ -368,11 +369,12 @@ LN882X_BOARD_PINS = {
|
||||
"WIRE0_SDA_5": 5,
|
||||
"WIRE0_SDA_6": 6,
|
||||
"WIRE0_SDA_7": 9,
|
||||
"WIRE0_SDA_8": 11,
|
||||
"WIRE0_SDA_9": 20,
|
||||
"WIRE0_SDA_10": 21,
|
||||
"WIRE0_SDA_11": 22,
|
||||
"WIRE0_SDA_12": 25,
|
||||
"WIRE0_SDA_8": 10,
|
||||
"WIRE0_SDA_9": 11,
|
||||
"WIRE0_SDA_10": 20,
|
||||
"WIRE0_SDA_11": 21,
|
||||
"WIRE0_SDA_12": 22,
|
||||
"WIRE0_SDA_13": 25,
|
||||
"SERIAL0_RX": 3,
|
||||
"SERIAL0_TX": 2,
|
||||
"SERIAL1_TX": 25,
|
||||
@@ -397,6 +399,7 @@ LN882X_BOARD_PINS = {
|
||||
"PA6": 6,
|
||||
"PA09": 9,
|
||||
"PA9": 9,
|
||||
"PA10": 10,
|
||||
"PA11": 11,
|
||||
"PB04": 20,
|
||||
"PB4": 20,
|
||||
@@ -421,7 +424,8 @@ LN882X_BOARD_PINS = {
|
||||
"D9": 22,
|
||||
"D10": 3,
|
||||
"D11": 2,
|
||||
"D12": 11,
|
||||
"D12": 10,
|
||||
"D13": 11,
|
||||
"A0": 0,
|
||||
"A1": 1,
|
||||
"A2": 4,
|
||||
|
||||
@@ -28,7 +28,6 @@ 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
|
||||
@@ -42,7 +41,6 @@ from ..defines import (
|
||||
CONF_RADIUS,
|
||||
CONF_SRC,
|
||||
CONF_START_ANGLE,
|
||||
LValidator,
|
||||
addr,
|
||||
literal,
|
||||
)
|
||||
@@ -59,7 +57,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_gradient_t, lv_point_precise_t
|
||||
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .img import CONF_IMAGE
|
||||
|
||||
@@ -223,6 +221,7 @@ RECT_PROPS = {
|
||||
"radius",
|
||||
"bg_opa",
|
||||
"bg_color",
|
||||
"bg_grad",
|
||||
"border_color",
|
||||
"border_width",
|
||||
"border_opa",
|
||||
@@ -240,30 +239,6 @@ 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):
|
||||
@@ -289,10 +264,10 @@ def _draw_line(layer, dsc, points):
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
**DRAW_SCHEMA,
|
||||
**DRAW_OPA_SCHEMA,
|
||||
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
||||
**RECT_SCHEMA,
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
@@ -448,8 +423,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,
|
||||
}
|
||||
@@ -499,7 +474,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),
|
||||
**POLYGON_SCHEMA,
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
},
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
@@ -528,7 +503,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", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
|
||||
@@ -65,7 +64,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, create_style
|
||||
from ..styles import LVStyle
|
||||
from ..types import (
|
||||
LV_EVENT,
|
||||
LvCompound,
|
||||
@@ -101,12 +100,6 @@ 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
|
||||
@@ -430,9 +423,11 @@ class MeterType(WidgetType):
|
||||
get_warnings().add(
|
||||
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
|
||||
)
|
||||
style_var = await create_style(_arc_style_name(iid.id), props)
|
||||
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
|
||||
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
|
||||
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
|
||||
lv.scale_section_set_style(
|
||||
tvar, LV_PART.MAIN, await arc_style.get_var()
|
||||
)
|
||||
lw = Widget.create(iid, tvar, arc_indicator_type)
|
||||
lw.parent = scale_var
|
||||
await set_indicator_values(scale_var, lw, v)
|
||||
@@ -615,15 +610,6 @@ 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,12 +4,10 @@
|
||||
|
||||
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
|
||||
@@ -25,7 +23,6 @@ from esphome.const import (
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
@@ -309,57 +306,6 @@ 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.
|
||||
@@ -725,14 +671,32 @@ class DriverChip:
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Check that the components this model needs are configured, see check_model_requirements().
|
||||
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.
|
||||
|
||||
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
|
||||
)
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def requires_buffer(config) -> bool:
|
||||
|
||||
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
|
||||
}
|
||||
|
||||
|
||||
def model_schema(config: ConfigType) -> cv.Schema:
|
||||
def model_schema(config: ConfigType) -> cv.All:
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
@@ -97,14 +97,16 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
# 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")
|
||||
return display.FULL_DISPLAY_SCHEMA.extend(
|
||||
schema = 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)
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
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(
|
||||
@@ -138,6 +140,11 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
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)
|
||||
@@ -148,13 +155,9 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(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
|
||||
config = model_schema(config)(config)
|
||||
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"},
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
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"},
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
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"},
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
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"},
|
||||
requires={"psram", "xl9535"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -154,7 +154,9 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
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(
|
||||
@@ -224,14 +226,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"},
|
||||
requires={"psram", "xl9535"},
|
||||
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"},
|
||||
requires={"psram", "xl9535"},
|
||||
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"},
|
||||
requires={"psram", "pca9554"},
|
||||
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"},
|
||||
requires={"psram", "ch422g"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
|
||||
@@ -197,7 +197,9 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MipiSpi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
||||
COLOR_ORDERS, upper=True
|
||||
),
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,12 +12,8 @@ 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
|
||||
|
||||
@@ -73,17 +69,7 @@ def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
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(
|
||||
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from esphome import automation, preferences
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import logger, network, socket
|
||||
from esphome.components.const import CONF_VERIFY_SSL
|
||||
@@ -107,7 +107,6 @@ 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"
|
||||
|
||||
@@ -305,10 +304,6 @@ 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()
|
||||
@@ -399,7 +394,6 @@ 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
|
||||
@@ -672,10 +666,6 @@ 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 return_code);
|
||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t qos);
|
||||
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,9 +170,8 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) {
|
||||
|
||||
case MQTT_EVENT_SUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
// 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]));
|
||||
// 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);
|
||||
break;
|
||||
case MQTT_EVENT_UNSUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
@@ -225,16 +224,6 @@ 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,7 +14,6 @@
|
||||
#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 {
|
||||
|
||||
@@ -215,9 +214,6 @@ 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;
|
||||
|
||||
@@ -298,10 +294,6 @@ 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,16 +21,6 @@
|
||||
#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
|
||||
@@ -42,39 +32,6 @@ 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",
|
||||
@@ -123,19 +80,6 @@ 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(
|
||||
@@ -417,9 +361,6 @@ 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();
|
||||
@@ -444,30 +385,14 @@ 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
|
||||
#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_();
|
||||
// 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);
|
||||
}
|
||||
#else
|
||||
this->subscribe_crash_log_();
|
||||
#endif
|
||||
#endif
|
||||
this->send_device_info_();
|
||||
|
||||
@@ -516,12 +441,7 @@ 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
|
||||
@@ -579,69 +499,15 @@ 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
|
||||
}
|
||||
}
|
||||
bool MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
bool all_subscribed = true;
|
||||
void MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
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
|
||||
@@ -706,10 +572,6 @@ __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,9 +23,6 @@
|
||||
#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>
|
||||
@@ -383,18 +380,10 @@ 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);
|
||||
/// 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
|
||||
void resubscribe_subscriptions_();
|
||||
|
||||
MQTTCredentials credentials_;
|
||||
/// The last will message. Disabled optional denotes it being default and
|
||||
@@ -451,18 +440,6 @@ 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)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#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,7 +31,6 @@ 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,7 +33,6 @@ 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,
|
||||
@@ -54,9 +53,10 @@ def AUTO_LOAD() -> list[str]:
|
||||
|
||||
|
||||
def _validate_tft_upload(config):
|
||||
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")
|
||||
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"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -105,9 +105,6 @@ 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,
|
||||
@@ -139,7 +136,6 @@ 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,
|
||||
@@ -248,14 +244,6 @@ 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,19 +1089,6 @@ 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.
|
||||
*
|
||||
@@ -1586,8 +1573,7 @@ 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)
|
||||
uint16_t tft_upload_http_buffer_size_tx_{0}; ///< HTTP TX buffer in bytes (0 = ESP-IDF default)
|
||||
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
|
||||
|
||||
/**
|
||||
* will request 4096 bytes chunks from the web server
|
||||
|
||||
@@ -283,7 +283,6 @@ 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,7 +44,6 @@ from esphome.const import (
|
||||
CONF_INVERTED,
|
||||
CONF_NUMBER,
|
||||
CONF_OTA,
|
||||
CONF_PLATFORM,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SAFE_MODE,
|
||||
CONF_TOOLCHAIN,
|
||||
@@ -310,8 +309,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
|
||||
): cv.Schema(
|
||||
{
|
||||
# Defaults to whether MCUboot is present, see _final_validate
|
||||
cv.Optional(CONF_ENABLE_OTA_ROLLBACK): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_ENABLE_OTA_ROLLBACK, default=True
|
||||
): cv.boolean,
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -370,21 +370,7 @@ def _final_validate(config):
|
||||
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
|
||||
)
|
||||
|
||||
# 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):
|
||||
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
|
||||
# "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]
|
||||
@@ -848,58 +834,26 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: PYOCD upload
|
||||
|
||||
if mcumgr_device:
|
||||
# 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
|
||||
# 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" or is_ble_address(host):
|
||||
asyncio.run(_upload_over_ble(config, host))
|
||||
return True # Handled: BLE OTA 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
|
||||
|
||||
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
|
||||
|
||||
@@ -39,6 +39,10 @@ RTL87XX_BOARDS = {
|
||||
"name": "Generic - RTL8710BN (2M/788k)",
|
||||
"family": FAMILY_RTL8710B,
|
||||
},
|
||||
"generic-rtl8710bx-1mb-468k": {
|
||||
"name": "Generic - RTL8710BX (1M/468k)",
|
||||
"family": FAMILY_RTL8710B,
|
||||
},
|
||||
"generic-rtl8710bx-4mb-980k": {
|
||||
"name": "Generic - RTL8710BX (4M/980k)",
|
||||
"family": FAMILY_RTL8710B,
|
||||
@@ -595,6 +599,103 @@ RTL87XX_BOARD_PINS = {
|
||||
"A0": 19,
|
||||
"A1": 41,
|
||||
},
|
||||
"generic-rtl8710bx-1mb-468k": {
|
||||
"SPI0_CS": 19,
|
||||
"SPI0_FCS": 6,
|
||||
"SPI0_FD0": 9,
|
||||
"SPI0_FD1": 7,
|
||||
"SPI0_FD2": 8,
|
||||
"SPI0_FD3": 11,
|
||||
"SPI0_FSCK": 10,
|
||||
"SPI0_MISO": 22,
|
||||
"SPI0_MOSI": 23,
|
||||
"SPI0_SCK": 18,
|
||||
"SPI1_CS": 19,
|
||||
"SPI1_MISO": 22,
|
||||
"SPI1_MOSI": 23,
|
||||
"SPI1_SCK": 18,
|
||||
"WIRE0_SCL_0": 22,
|
||||
"WIRE0_SCL_1": 29,
|
||||
"WIRE0_SDA_0": 19,
|
||||
"WIRE0_SDA_1": 30,
|
||||
"WIRE1_SCL": 18,
|
||||
"WIRE1_SDA": 23,
|
||||
"SERIAL0_CTS": 19,
|
||||
"SERIAL0_RTS": 22,
|
||||
"SERIAL0_RX": 18,
|
||||
"SERIAL0_TX": 23,
|
||||
"SERIAL2_RX": 29,
|
||||
"SERIAL2_TX": 30,
|
||||
"ADC1": 19,
|
||||
"CS0": 19,
|
||||
"CS1": 19,
|
||||
"CTS0": 19,
|
||||
"FCS": 6,
|
||||
"FD0": 9,
|
||||
"FD1": 7,
|
||||
"FD2": 8,
|
||||
"FD3": 11,
|
||||
"FSCK": 10,
|
||||
"MISO0": 22,
|
||||
"MISO1": 22,
|
||||
"MOSI0": 23,
|
||||
"MOSI1": 23,
|
||||
"PA00": 0,
|
||||
"PA0": 0,
|
||||
"PA05": 5,
|
||||
"PA5": 5,
|
||||
"PA06": 6,
|
||||
"PA6": 6,
|
||||
"PA07": 7,
|
||||
"PA7": 7,
|
||||
"PA08": 8,
|
||||
"PA8": 8,
|
||||
"PA09": 9,
|
||||
"PA9": 9,
|
||||
"PA10": 10,
|
||||
"PA11": 11,
|
||||
"PA12": 12,
|
||||
"PA14": 14,
|
||||
"PA15": 15,
|
||||
"PA18": 18,
|
||||
"PA19": 19,
|
||||
"PA22": 22,
|
||||
"PA23": 23,
|
||||
"PA29": 29,
|
||||
"PA30": 30,
|
||||
"PWM1": 15,
|
||||
"PWM2": 0,
|
||||
"PWM3": 12,
|
||||
"PWM4": 30,
|
||||
"PWM5": 22,
|
||||
"RTS0": 22,
|
||||
"RX0": 18,
|
||||
"RX2": 29,
|
||||
"SCK0": 18,
|
||||
"SCK1": 18,
|
||||
"SCL1": 18,
|
||||
"SDA1": 23,
|
||||
"TX0": 23,
|
||||
"TX2": 30,
|
||||
"D0": 0,
|
||||
"D1": 5,
|
||||
"D2": 6,
|
||||
"D3": 7,
|
||||
"D4": 8,
|
||||
"D5": 9,
|
||||
"D6": 10,
|
||||
"D7": 11,
|
||||
"D8": 12,
|
||||
"D9": 14,
|
||||
"D10": 15,
|
||||
"D11": 18,
|
||||
"D12": 19,
|
||||
"D13": 22,
|
||||
"D14": 23,
|
||||
"D15": 29,
|
||||
"D16": 30,
|
||||
"A0": 19,
|
||||
},
|
||||
"generic-rtl8710bx-4mb-980k": {
|
||||
"SPI0_CS": 19,
|
||||
"SPI0_FCS": 6,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, sensor
|
||||
@@ -29,22 +27,11 @@ 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"
|
||||
@@ -203,9 +190,6 @@ 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
|
||||
|
||||
|
||||
@@ -215,9 +199,9 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||
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_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||
cv.Exclusive(
|
||||
@@ -232,7 +216,7 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
climate.climate_schema(TemplateClimate, temperature_unit=True)
|
||||
climate.climate_schema(TemplateClimate)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
@@ -306,30 +290,6 @@ 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)
|
||||
@@ -407,11 +367,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
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_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_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),
|
||||
@@ -440,13 +400,16 @@ automation.register_apply_action(
|
||||
"climate.template.publish",
|
||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||
automation.ApplyField(
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
"current_temperature = {}",
|
||||
cg.float_,
|
||||
temperature_in_entity_unit,
|
||||
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
|
||||
),
|
||||
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
|
||||
*climate.TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
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_
|
||||
),
|
||||
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,7 +2,6 @@
|
||||
|
||||
#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"
|
||||
@@ -23,12 +22,6 @@ 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,7 +14,6 @@ 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
|
||||
|
||||
@@ -38,7 +37,7 @@ RESTORE_MODES = TemplateWaterHeaterRestoreMode.options(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
|
||||
water_heater.water_heater_schema(TemplateWaterHeater)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||
@@ -123,8 +122,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_MODE): cv.templatable(
|
||||
water_heater.validate_water_heater_mode
|
||||
),
|
||||
@@ -143,16 +142,13 @@ async def water_heater_template_publish_to_code(
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
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 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_))
|
||||
|
||||
if mode := config.get(CONF_MODE):
|
||||
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
|
||||
|
||||
@@ -32,7 +32,6 @@ 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,7 +29,6 @@ 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;
|
||||
}
|
||||
@@ -55,7 +54,6 @@ 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};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -8,19 +6,15 @@ 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"]
|
||||
@@ -48,36 +42,19 @@ 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_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_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(
|
||||
@@ -85,13 +62,8 @@ 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),
|
||||
@@ -116,7 +88,6 @@ 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,16 +218,13 @@ 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_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
|
||||
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
|
||||
cv.only_on_esp32(value)
|
||||
return value
|
||||
|
||||
@@ -371,21 +368,18 @@ def wifi_network_ap(value):
|
||||
if value is None:
|
||||
value = {}
|
||||
config = WIFI_NETWORK_AP(value)
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
@@ -438,15 +432,6 @@ 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")
|
||||
|
||||
@@ -474,34 +459,24 @@ def final_validate(config):
|
||||
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
|
||||
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 = [
|
||||
@@ -870,15 +845,10 @@ 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,17 +404,6 @@ 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
|
||||
@@ -668,19 +657,18 @@ void WiFiComponent::setup() {
|
||||
this->configured_power_save_ = this->power_save_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || defined(WIFI_AP_HAS_PORTAL))
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
|
||||
// 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. An AP that
|
||||
// stays up only loses its portal.
|
||||
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
|
||||
// carries no portal and stays up.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
#ifdef USE_WIFI_DPP
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
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
|
||||
@@ -693,8 +681,7 @@ void WiFiComponent::setup() {
|
||||
#endif
|
||||
this->disable_ap_();
|
||||
}
|
||||
#endif // !USE_WIFI_AP_PERSISTENT
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
#endif // USE_WIFI_AP
|
||||
});
|
||||
}
|
||||
#endif
|
||||
@@ -796,12 +783,18 @@ void WiFiComponent::start() {
|
||||
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
|
||||
this->start_initial_connection_();
|
||||
#endif
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
} else if (this->has_ap()) { // a bridge without a portal waits in loop() for the upstream's resolver
|
||||
#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
|
||||
this->setup_ap_config_();
|
||||
this->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
|
||||
this->start_ap_portal_();
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
}
|
||||
#ifdef USE_WIFI_DPP
|
||||
// Without networks there is nothing to try first, so listen for a configurator right away
|
||||
@@ -941,7 +934,7 @@ void WiFiComponent::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
// 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.
|
||||
@@ -971,13 +964,7 @@ void WiFiComponent::loop() {
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
}
|
||||
#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
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
|
||||
#ifdef USE_IMPROV_BLE
|
||||
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
|
||||
@@ -1015,24 +1002,13 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
#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_() &&
|
||||
(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
|
||||
|
||||
@@ -1126,9 +1102,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_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
// 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 an AP that stays up would take clients it cannot serve.
|
||||
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
|
||||
if (!this->ap_setup_)
|
||||
this->wifi_mode_({}, false);
|
||||
#endif
|
||||
@@ -1177,21 +1153,22 @@ void WiFiComponent::pause_exclusive_ap_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
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
|
||||
this->end_ap_portal_();
|
||||
// The AP may come back later; its clients dropped with it without a disconnect event for each
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
void WiFiComponent::fail_bridge_() {
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::disable_ap_() {
|
||||
this->forget_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
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
}
|
||||
|
||||
void WiFiComponent::set_ap(const WiFiAP &ap) {
|
||||
@@ -1526,12 +1503,14 @@ void WiFiComponent::disable() {
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
this->wifi_disconnect_();
|
||||
#ifdef USE_WIFI_AP
|
||||
// The AP goes down with the radio; enable() has to configure it, and its portal, again
|
||||
this->forget_ap_();
|
||||
#endif
|
||||
this->wifi_mode_(false, false);
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
#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;
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
this->ap_changed_ = 0; // at once, not after ap_timeout
|
||||
#endif
|
||||
}
|
||||
@@ -1830,13 +1809,9 @@ 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;
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
|
||||
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
|
||||
@@ -2477,23 +2452,8 @@ 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_())
|
||||
@@ -2502,10 +2462,6 @@ 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,7 +460,6 @@ 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 {
|
||||
@@ -740,8 +739,6 @@ 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_();
|
||||
@@ -753,16 +750,11 @@ 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
|
||||
|
||||
@@ -891,10 +883,6 @@ 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
|
||||
@@ -1042,7 +1030,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_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
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
|
||||
@@ -1161,10 +1149,6 @@ 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->disable_ap_();
|
||||
this->fail_bridge_();
|
||||
}
|
||||
#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 (with a portal, the AP's own
|
||||
// address while there is none). A failure fails the setup and it is tried again.
|
||||
// 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.
|
||||
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 upstream_dns(size_t i) {
|
||||
static uint32_t ap_dns_to_offer(size_t i) {
|
||||
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
|
||||
#else
|
||||
@@ -1660,45 +1660,14 @@ static uint32_t upstream_dns(size_t i) {
|
||||
#endif
|
||||
}
|
||||
|
||||
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::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
|
||||
|
||||
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, own);
|
||||
if (addr == 0) {
|
||||
ok = ok && !own; // the AP's own address could not be read
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
if (addr == 0)
|
||||
continue;
|
||||
}
|
||||
esp_netif_dns_info_t info{};
|
||||
info.ip.type = ESP_IPADDR_TYPE_V4;
|
||||
info.ip.u_addr.ip4.addr = addr;
|
||||
@@ -1716,65 +1685,28 @@ 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 AP up, and its setup writes the resolvers
|
||||
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
|
||||
// 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), 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
|
||||
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
|
||||
bool changed = false;
|
||||
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
|
||||
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
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 && 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));
|
||||
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->disable_ap_();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (offered_own != this->ap_offers_own_dns_)
|
||||
this->drop_ap_clients_();
|
||||
#endif
|
||||
if (!ok)
|
||||
this->fail_bridge_();
|
||||
}
|
||||
|
||||
#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,8 +78,6 @@ _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)
|
||||
@@ -95,9 +93,6 @@ 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,14 +181,7 @@ 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]() {
|
||||
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);
|
||||
});
|
||||
global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); });
|
||||
}
|
||||
|
||||
static void auth_pairing_confirm(bt_conn *conn) {
|
||||
|
||||
@@ -1264,18 +1264,6 @@ 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"""
|
||||
|
||||
@@ -1449,7 +1437,6 @@ 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,9 +78,6 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
@@ -89,7 +86,6 @@ from esphome.core import (
|
||||
CORE,
|
||||
HexInt,
|
||||
Lambda,
|
||||
TemperatureWithUnit,
|
||||
TimePeriod,
|
||||
TimePeriodMicroseconds,
|
||||
TimePeriodMilliseconds,
|
||||
@@ -98,7 +94,6 @@ 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 (
|
||||
@@ -1525,7 +1520,7 @@ METRIC_SUFFIXES = {
|
||||
|
||||
def float_with_unit(quantity, regex_suffix, optional_unit=False):
|
||||
pattern = re.compile(
|
||||
f"^([-+]?(?:[0-9]+\\.?[0-9]*|\\.[0-9]+))\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
)
|
||||
|
||||
def validator(value):
|
||||
@@ -1562,73 +1557,53 @@ 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_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)")
|
||||
_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)
|
||||
|
||||
|
||||
class TemperatureUnitValue(str):
|
||||
"""A unit from temperature_unit(): the platform took the option, so the entity reports in it."""
|
||||
def temperature(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
|
||||
__slots__ = ()
|
||||
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
|
||||
|
||||
|
||||
def temperature_unit(value: Any) -> TemperatureUnitValue:
|
||||
"""Validate the unit an entity reports its temperatures in."""
|
||||
return TemperatureUnitValue(one_of(*TEMPERATURE_UNITS)(value))
|
||||
def temperature_delta(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
|
||||
try:
|
||||
return _temperature_k(value)
|
||||
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)
|
||||
try:
|
||||
fahrenheit = _temperature_f(value)
|
||||
return fahrenheit * (5 / 9)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
|
||||
# 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)
|
||||
raise err
|
||||
|
||||
|
||||
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
|
||||
|
||||
@@ -1269,7 +1269,6 @@ UNIT_DECIBEL_MILLIWATT = "dBm"
|
||||
UNIT_DEGREE_PER_SECOND = "°/s"
|
||||
UNIT_DEGREES = "°"
|
||||
UNIT_EMPTY = ""
|
||||
UNIT_FAHRENHEIT = "°F"
|
||||
UNIT_G = "G"
|
||||
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
|
||||
UNIT_HECTOPASCAL = "hPa"
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_BUILD_PATH,
|
||||
@@ -100,16 +100,6 @@ def is_approximately_integer(value):
|
||||
return abs(value - round(value)) < 0.001
|
||||
|
||||
|
||||
class TemperatureWithUnit(NamedTuple):
|
||||
"""A temperature as written: the number and the unit it came with, None without one."""
|
||||
|
||||
value: float
|
||||
unit: str | None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.value}{self.unit or ''}"
|
||||
|
||||
|
||||
class TimePeriod:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -381,7 +381,6 @@
|
||||
#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host
|
||||
#define USE_MQTT
|
||||
#define USE_MQTT_ABBREVIATIONS
|
||||
#define USE_MQTT_SESSION_PERSISTENCE
|
||||
#define USE_MQTT_COVER_JSON
|
||||
#define USE_WIREGUARD
|
||||
#endif
|
||||
@@ -555,9 +554,10 @@
|
||||
#define USE_WIFI_FAST_CONNECT_IN_FLASH
|
||||
#define USE_WIFI_LEASE_CACHE
|
||||
#define USE_WIFI_LEASE_CACHE_IN_FLASH
|
||||
#define USE_WIFI_AP_PERSISTENT
|
||||
#define USE_WIFI_AP_WISP
|
||||
#define USE_WIFI_AP_WISP_PORTAL
|
||||
#define USE_WIFI_AP_WISP_STATIC_DNS
|
||||
#define WIFI_AP_WISP_DNS_0 0x08080808
|
||||
#define WIFI_AP_WISP_DNS_1 0x04040808
|
||||
#define USE_WIFI_PHY_MODE
|
||||
#define USE_WIFI_IP_STATE_LISTENERS
|
||||
#define USE_WIFI_SCAN_RESULTS_LISTENERS
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import functools
|
||||
from importlib import import_module
|
||||
@@ -20,23 +20,18 @@ from esphome.const import (
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_MQTT_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_REPORT,
|
||||
CONF_SORTING_GROUP_ID,
|
||||
CONF_SORTING_WEIGHT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USE_DEVICE_TYPE,
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WEB_SERVER_ID,
|
||||
CONF_ZIGBEE_BINARY_SENSOR,
|
||||
CONF_ZIGBEE_ID,
|
||||
CONF_ZIGBEE_SENSOR,
|
||||
UNIT_CELSIUS,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.config import (
|
||||
@@ -52,7 +47,7 @@ from esphome.cpp_generator import (
|
||||
add,
|
||||
get_variable,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS, App
|
||||
from esphome.cpp_types import App
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import (
|
||||
cpp_string_escape,
|
||||
@@ -61,7 +56,6 @@ from esphome.helpers import (
|
||||
sanitize,
|
||||
snake_case,
|
||||
)
|
||||
from esphome.loader import get_component, get_platform
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
||||
|
||||
@@ -627,6 +621,12 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
||||
[CONF_SENSOR, 1, CONF_POWER_ID].
|
||||
"""
|
||||
|
||||
def _walk_config(config, path):
|
||||
walk = [path] if not isinstance(path, list) else path
|
||||
for item_or_index in walk:
|
||||
config = config[item_or_index]
|
||||
return config
|
||||
|
||||
def inherit_property(config):
|
||||
# Split the property into its path and name
|
||||
if not isinstance(property_to_inherit, list):
|
||||
@@ -662,171 +662,9 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
||||
|
||||
return config
|
||||
|
||||
inherit_property.inherits = (property_to_inherit, parent_id_property, transform)
|
||||
return inherit_property
|
||||
|
||||
|
||||
def _walk_config(config: ConfigType, path: str | list) -> Any:
|
||||
for item_or_index in path if isinstance(path, list) else [path]:
|
||||
config = config[item_or_index]
|
||||
return config
|
||||
|
||||
|
||||
def _inherit_validators(validator: Any) -> Iterator[Callable]:
|
||||
"""The inherit_property_from validators in a FINAL_VALIDATE_SCHEMA, also inside cv.All."""
|
||||
if hasattr(validator, "inherits"):
|
||||
yield validator
|
||||
for inner in getattr(validator, "validators", ()):
|
||||
yield from _inherit_validators(inner)
|
||||
|
||||
|
||||
def _inherits(manifest: Any, property_: str) -> tuple | None:
|
||||
"""What ``inherit_property_from`` gives ``property_`` in a component's final validation."""
|
||||
validators = _inherit_validators(manifest.final_validate_schema)
|
||||
return next((v.inherits for v in validators if v.inherits[0] == property_), None)
|
||||
|
||||
|
||||
# Returned by resolve_inherited_property when the chain cannot be followed
|
||||
UNRESOLVED_PROPERTY = object()
|
||||
|
||||
|
||||
def resolve_inherited_property(entity_id: ID, property_: str) -> Any:
|
||||
"""The value of ``property_`` for the entity that declares ``entity_id``, following the
|
||||
``inherit_property_from`` chain (a copy of a copy) the way final validation fills it in, so
|
||||
the result does not depend on which component validates first. None when it is not set;
|
||||
``UNRESOLVED_PROPERTY`` when the chain cannot be followed (a cycle, an unknown component or
|
||||
an entity nested under a component that is not a platform)."""
|
||||
fconf = fv.full_config.get()
|
||||
seen: set[ID] = set()
|
||||
steps: list[tuple[Callable | None, ConfigType]] = []
|
||||
value = None
|
||||
while entity_id not in seen:
|
||||
seen.add(entity_id)
|
||||
path = fconf.get_path_for_id(entity_id)[:-1]
|
||||
config = fconf.get_config_for_path(path)
|
||||
if property_ in config:
|
||||
value = config[property_]
|
||||
break
|
||||
if (platform := config.get(CONF_PLATFORM)) is not None:
|
||||
manifest = get_platform(path[0], platform)
|
||||
elif len(path) <= 2:
|
||||
manifest = get_component(path[0])
|
||||
else:
|
||||
return UNRESOLVED_PROPERTY
|
||||
if manifest is None:
|
||||
return UNRESOLVED_PROPERTY
|
||||
if (inherits := _inherits(manifest, property_)) is None:
|
||||
return None
|
||||
_, parent_id_property, transform = inherits
|
||||
steps.append((transform, config))
|
||||
entity_id = _walk_config(config, parent_id_property)
|
||||
else:
|
||||
return UNRESOLVED_PROPERTY
|
||||
for transform, config in reversed(steps):
|
||||
if transform:
|
||||
value = transform(value, config)
|
||||
return value
|
||||
|
||||
|
||||
# Takes the entity config and the full config, and returns the unit the entity reports in
|
||||
TemperatureUnitResolver = Callable[[ConfigType, ConfigType], str]
|
||||
|
||||
# Platforms whose entities report in one unit only, set by their traits, or a resolver for those
|
||||
# whose unit follows their own (or their hub's) config; keyed by the C++ class name
|
||||
_FIXED_TEMPERATURE_UNITS: dict[str, str | TemperatureUnitResolver] = {}
|
||||
|
||||
|
||||
def declare_fixed_temperature_unit(
|
||||
class_: MockObjClass, unit: str | TemperatureUnitResolver
|
||||
) -> None:
|
||||
"""Record the unit every entity of ``class_`` reports in, or a resolver that reads it from the
|
||||
config; nothing is stored in the config, so the unit never appears in a config dump."""
|
||||
_FIXED_TEMPERATURE_UNITS[str(class_)] = unit
|
||||
|
||||
|
||||
def _declared_temperature_unit(type_: Any) -> str | TemperatureUnitResolver:
|
||||
"""The unit declared for ``type_`` or its closest declared parent class, else Celsius, so a
|
||||
platform built on a declared class (an external component) reports in the same unit."""
|
||||
classes = [type_, *type_.parents] if isinstance(type_, MockObjClass) else [type_]
|
||||
for class_ in classes:
|
||||
if (declared := _FIXED_TEMPERATURE_UNITS.get(str(class_))) is not None:
|
||||
return declared
|
||||
return UNIT_CELSIUS
|
||||
|
||||
|
||||
def entity_temperature_unit(
|
||||
config: ConfigType, full_config: ConfigType | None = None
|
||||
) -> str | None:
|
||||
"""The unit a climate or water heater reports in: ``unit_of_measurement`` when the platform took
|
||||
the option through its schema factory, the platform's fixed or resolved unit when it declared
|
||||
one, else Celsius (anova's option of that name is unrelated). A resolver needs ``full_config``;
|
||||
without it the unit is not known yet and this returns None."""
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
if isinstance(unit, cv.TemperatureUnitValue):
|
||||
return unit
|
||||
entity_id = config.get(CONF_ID)
|
||||
declared = _declared_temperature_unit(
|
||||
entity_id.type if entity_id is not None else None
|
||||
)
|
||||
if isinstance(declared, str):
|
||||
return declared
|
||||
if full_config is None:
|
||||
return None
|
||||
return declared(config, full_config)
|
||||
|
||||
|
||||
def entity_temperature_unit_of_id(entity_id: ID) -> str:
|
||||
"""The unit of the entity with this id, for actions that take static temperatures."""
|
||||
return entity_temperature_unit(
|
||||
fv.get_declaring_config(entity_id, CORE.config), CORE.config
|
||||
)
|
||||
|
||||
|
||||
def convert_temperature_keys(
|
||||
section: ConfigType, keys: tuple[str, ...], unit: str, *, delta: bool = False
|
||||
) -> None:
|
||||
"""Convert the ``(value, unit)`` temperatures under ``keys`` into ``unit``; absent or already
|
||||
converted values are left alone."""
|
||||
for key in keys:
|
||||
if isinstance(value := section.get(key), tuple):
|
||||
section[key] = cv.convert_temperature(*value, unit, delta=delta)
|
||||
|
||||
|
||||
def convert_visual_temperatures(
|
||||
config: ConfigType,
|
||||
delta_keys: tuple[str, ...],
|
||||
delta_section: str | None = None,
|
||||
full_config: ConfigType | None = None,
|
||||
) -> ConfigType:
|
||||
"""Convert a climate's or water heater's ``visual`` temperatures into the entity's unit. The
|
||||
step keys are differences, found under ``delta_section`` when the domain nests them. A unit
|
||||
that needs ``full_config`` is left for final validation, which runs this again with it."""
|
||||
if (unit := entity_temperature_unit(config, full_config)) is None:
|
||||
return config
|
||||
visual = config[CONF_VISUAL]
|
||||
convert_temperature_keys(visual, (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE), unit)
|
||||
section = visual if delta_section is None else visual.get(delta_section)
|
||||
if section is not None:
|
||||
convert_temperature_keys(section, delta_keys, unit, delta=True)
|
||||
return config
|
||||
|
||||
|
||||
def temperature_in_entity_unit(
|
||||
config: ConfigType, value: tuple[float, str | None]
|
||||
) -> str:
|
||||
"""Render a static action temperature in the unit of the entity the action targets."""
|
||||
unit = entity_temperature_unit_of_id(config[CONF_ID])
|
||||
return str(cg.safe_exp(cv.convert_temperature(*value, unit)))
|
||||
|
||||
|
||||
def setup_entity_temperature_unit(var: MockObj, config: ConfigType) -> None:
|
||||
"""Pass the unit an entity reports in to its platform, which took the option; a platform with a
|
||||
fixed unit sets it in its own traits."""
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
if isinstance(unit, cv.TemperatureUnitValue):
|
||||
add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
|
||||
|
||||
|
||||
def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Create a validator function to check for duplicate entity names.
|
||||
|
||||
|
||||
@@ -1809,15 +1809,6 @@ enum class TemperatureUnit : uint8_t {
|
||||
KELVIN = 2,
|
||||
};
|
||||
|
||||
/// Convert degrees Celsius into the given unit.
|
||||
constexpr float celsius_to(float value, TemperatureUnit unit) {
|
||||
if (unit == TemperatureUnit::FAHRENHEIT)
|
||||
return celsius_to_fahrenheit(value);
|
||||
if (unit == TemperatureUnit::KELVIN)
|
||||
return value + 273.15f;
|
||||
return value;
|
||||
}
|
||||
|
||||
///@}
|
||||
|
||||
/// @name Utilities
|
||||
|
||||
@@ -1331,11 +1331,6 @@ class MockObjClass(MockObj):
|
||||
# pylint: disable=protected-access
|
||||
self._parents += paren._parents
|
||||
|
||||
@property
|
||||
def parents(self) -> list["MockObjClass"]:
|
||||
"""Every ancestor class: each direct parent followed by its own parents."""
|
||||
return self._parents
|
||||
|
||||
def inherits_from(self, other: "MockObjClass") -> bool:
|
||||
if str(self) == str(other):
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.const import UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
global_ns = MockObj("", "")
|
||||
@@ -43,12 +42,6 @@ InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin)
|
||||
gpio_ns = esphome_ns.namespace("gpio")
|
||||
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
|
||||
EntityCategory = esphome_ns.enum("EntityCategory")
|
||||
TemperatureUnit = esphome_ns.enum("TemperatureUnit", is_class=True)
|
||||
TEMPERATURE_UNITS = {
|
||||
UNIT_CELSIUS: TemperatureUnit.CELSIUS,
|
||||
UNIT_FAHRENHEIT: TemperatureUnit.FAHRENHEIT,
|
||||
UNIT_KELVIN: TemperatureUnit.KELVIN,
|
||||
}
|
||||
Parented = esphome_ns.class_("Parented")
|
||||
ESPTime = esphome_ns.struct("ESPTime")
|
||||
StringRef = esphome_ns.class_("StringRef")
|
||||
+15
-113
@@ -1,10 +1,8 @@
|
||||
from collections.abc import Callable, Hashable
|
||||
from contextvars import ContextVar
|
||||
from functools import reduce
|
||||
import logging
|
||||
from logging import Logger
|
||||
import operator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -23,64 +21,17 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.schema_extractors import schema_extractor
|
||||
from esphome.types import ConfigType, SafeExpType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.config import Config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# A use of a pin: the path of the component using it, the pin provider's id and the pin config
|
||||
PinUse = tuple[list, Any, ConfigType]
|
||||
|
||||
# Set while validating pins that should give way to any other use of the same pin
|
||||
_validating_optional = ContextVar("validating_optional_pins", default=False)
|
||||
|
||||
|
||||
def _remove_value(conf: Any, target: Any) -> bool:
|
||||
"""Remove the object ``target`` from wherever it is nested in ``conf``."""
|
||||
items = conf.items() if isinstance(conf, dict) else enumerate(conf)
|
||||
for key, value in items:
|
||||
if value is target:
|
||||
del conf[key]
|
||||
return True
|
||||
if isinstance(value, (dict, list)) and _remove_value(value, target):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PinRegistry(dict):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pins_used = {}
|
||||
# Entries of pins_used that give way to other uses, keyed by id()
|
||||
self.optional_pins = {}
|
||||
|
||||
def reset(self):
|
||||
self.pins_used = {}
|
||||
self.optional_pins = {}
|
||||
|
||||
def optional(self, validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
"""
|
||||
Wrap a pin validator so the pins it validates give way to any other use of the
|
||||
same pin. Instead of the pin being reported as used in multiple places,
|
||||
remove_optional_conflicts() removes it from the config, unless every other use
|
||||
sets allow_other_uses, in which case the pin is shared. Use this for pins a
|
||||
component supplies as defaults that a user may want to take over.
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def wrapper(value: Any) -> Any:
|
||||
if value == SCHEMA_EXTRACT:
|
||||
return validator
|
||||
token = _validating_optional.set(True)
|
||||
try:
|
||||
return validator(value)
|
||||
finally:
|
||||
_validating_optional.reset(token)
|
||||
|
||||
return wrapper
|
||||
|
||||
def get_count(self, key, id, number):
|
||||
"""
|
||||
@@ -135,10 +86,7 @@ class PinRegistry(dict):
|
||||
self.pins_used[pin_key] = []
|
||||
# client_id identifies the instance of the providing component
|
||||
client_id = result.get(key)
|
||||
entry = (path_context.get(), client_id, result)
|
||||
self.pins_used[pin_key].append(entry)
|
||||
if _validating_optional.get():
|
||||
self.optional_pins[id(entry)] = entry
|
||||
self.pins_used[pin_key].append((path_context.get(), client_id, result))
|
||||
# return the validated pin config
|
||||
return result
|
||||
|
||||
@@ -162,69 +110,23 @@ class PinRegistry(dict):
|
||||
key = self.get_key(key) if isinstance(key, dict) else key
|
||||
return self[key][0]
|
||||
|
||||
def _grouped(self) -> dict[tuple, list[PinUse]]:
|
||||
"""Group pin uses by (schema key, resolved provider id, pin number)."""
|
||||
# Regroup by (schema key, resolved provider id, pin number) rather than
|
||||
# the validate()-time bucket, so the same physical pin reached via an
|
||||
# explicit id and via {address: ...} (or an omitted id) is recognized as
|
||||
# one reuse group regardless of which syntax was used to reference the hub.
|
||||
grouped: dict[tuple, list[PinUse]] = {}
|
||||
for (key, _, number), pin_list in self.pins_used.items():
|
||||
for entry in pin_list:
|
||||
client_id = entry[1]
|
||||
provider_id = client_id.id if isinstance(client_id, ID) else client_id
|
||||
grouped.setdefault((key, provider_id, number), []).append(entry)
|
||||
return grouped
|
||||
|
||||
def remove_optional_conflicts(self, fconf: "Config") -> None:
|
||||
"""
|
||||
Remove optional pins that are also used elsewhere from the config, or mark them
|
||||
as shared if every other use sets allow_other_uses. This runs once ids are resolved and before the components' final validation,
|
||||
so those only see the pins that will be used.
|
||||
:param fconf: The full config
|
||||
"""
|
||||
if not self.optional_pins:
|
||||
return
|
||||
for (key, provider_id, _), pin_list in self._grouped().items():
|
||||
optional = [e for e in pin_list if id(e) in self.optional_pins]
|
||||
others = [e for e in pin_list if id(e) not in self.optional_pins]
|
||||
if not optional or not others:
|
||||
continue
|
||||
if all(e[2].get(CONF_ALLOW_OTHER_USES, False) for e in others):
|
||||
# The other uses ask to share the pin, so share it with the optional ones too
|
||||
for _, _, pin_config in optional:
|
||||
pin_config[CONF_ALLOW_OTHER_USES] = True
|
||||
continue
|
||||
for entry in optional:
|
||||
self._remove_optional(fconf, key, provider_id, entry)
|
||||
|
||||
def _remove_optional(
|
||||
self, fconf: "Config", key: str, provider_id: str | None, entry: PinUse
|
||||
) -> None:
|
||||
"""
|
||||
Remove an optional pin from the config and from the pins used. If it cannot be
|
||||
found in the config it is kept, so final_validate() reports the conflict.
|
||||
"""
|
||||
pin_path, _, pin_config = entry
|
||||
if not _remove_value(fconf.get_config_for_path(pin_path), pin_config):
|
||||
return
|
||||
for pin_list in self.pins_used.values():
|
||||
pin_list[:] = [e for e in pin_list if e is not entry]
|
||||
del self.optional_pins[id(entry)]
|
||||
pin = f"{key} {provider_id} pin" if provider_id is not None else "Pin"
|
||||
_LOGGER.info(
|
||||
"%s %s is used elsewhere, so is not applied as a default for %s",
|
||||
pin,
|
||||
pin_config[CONF_NUMBER],
|
||||
"->".join(str(p) for p in pin_path),
|
||||
)
|
||||
|
||||
def final_validate(self, fconf):
|
||||
"""
|
||||
Run the final validation for all pins, and check for reuse
|
||||
:param fconf: The full config
|
||||
"""
|
||||
for (key, _, _), pin_list in self._grouped().items():
|
||||
# Regroup by (schema key, resolved provider id, pin number) rather than
|
||||
# the validate()-time bucket, so the same physical pin reached via an
|
||||
# explicit id and via {address: ...} (or an omitted id) is recognized as
|
||||
# one reuse group regardless of which syntax was used to reference the hub.
|
||||
grouped: dict[tuple, list] = {}
|
||||
for (key, _, number), pin_list in self.pins_used.items():
|
||||
for entry in pin_list:
|
||||
client_id = entry[1]
|
||||
provider_id = client_id.id if isinstance(client_id, ID) else client_id
|
||||
grouped.setdefault((key, provider_id, number), []).append(entry)
|
||||
|
||||
for (key, _, _), pin_list in grouped.items():
|
||||
count = len(pin_list) # number of places same pin used.
|
||||
final_val_fun = self[key][2] # final validation function
|
||||
for pin_path, client_id, pin_config in pin_list:
|
||||
|
||||
@@ -34,7 +34,6 @@ from esphome.core import (
|
||||
EsphomeError,
|
||||
Lambda,
|
||||
MACAddress,
|
||||
TemperatureWithUnit,
|
||||
TimePeriod,
|
||||
)
|
||||
from esphome.expression import has_substitution_or_expression
|
||||
@@ -1632,10 +1631,6 @@ ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_string
|
||||
ESPHomeDumper.add_multi_representer(_BaseNetwork, ESPHomeDumper.represent_stringify)
|
||||
ESPHomeDumper.add_multi_representer(MACAddress, ESPHomeDumper.represent_stringify)
|
||||
ESPHomeDumper.add_multi_representer(TimePeriod, ESPHomeDumper.represent_stringify)
|
||||
# Dumped as written, unit included, so `esphome config` output validates again
|
||||
ESPHomeDumper.add_multi_representer(
|
||||
TemperatureWithUnit, ESPHomeDumper.represent_stringify
|
||||
)
|
||||
ESPHomeDumper.add_multi_representer(Lambda, ESPHomeDumper.represent_lambda)
|
||||
ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend)
|
||||
ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove)
|
||||
|
||||
+5
-2
@@ -239,7 +239,7 @@ build_unflags =
|
||||
; This are common settings for the LibreTiny (all variants) using Arduino.
|
||||
[common:libretiny-arduino]
|
||||
extends = common:arduino
|
||||
platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0
|
||||
platform = https://github.com/libretiny-eu/libretiny.git#v1.14.0
|
||||
framework = arduino
|
||||
lib_compat_mode = soft
|
||||
lib_deps =
|
||||
@@ -260,7 +260,6 @@ build_flags =
|
||||
build_unflags =
|
||||
${common.build_unflags}
|
||||
build_src_flags = -include Arduino.h
|
||||
extra_scripts = pre:esphome/components/libretiny/scons_dont_inherit.py.script
|
||||
|
||||
; This is the common settings for the nRF52 using Zephyr.
|
||||
[common:nrf52-zephyr]
|
||||
@@ -557,6 +556,8 @@ build_flags =
|
||||
-DUSE_LIBRETINY_VARIANT_BK7231N
|
||||
build_unflags =
|
||||
${common.build_unflags}
|
||||
; mirror the libretiny codegen pin: BK7231N and BK7231T build with BDK 3.0.78
|
||||
custom_versions.beken-bdk = 3.0.78
|
||||
|
||||
[env:ln882h-arduino]
|
||||
extends = common:libretiny-arduino
|
||||
@@ -594,6 +595,8 @@ build_unflags =
|
||||
[env:bk72xx-tidy]
|
||||
extends = common:libretiny-arduino
|
||||
board = generic-bk7231n-qfn32-tuya
|
||||
; mirror the libretiny codegen pin: BK7231N and BK7231T build with BDK 3.0.78
|
||||
custom_versions.beken-bdk = 3.0.78
|
||||
build_flags =
|
||||
${common:libretiny-arduino.build_flags}
|
||||
${flags:clangtidy.build_flags}
|
||||
|
||||
Whitespace-only changes.
@@ -1,13 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
logger:
|
||||
|
||||
zephyr_ble_server:
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
@@ -1,22 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
logger:
|
||||
|
||||
safe_mode:
|
||||
|
||||
zephyr_ble_server:
|
||||
on_numeric_comparison_request:
|
||||
then:
|
||||
- ble_server.numeric_comparison_reply:
|
||||
accept: true
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
on_begin:
|
||||
then:
|
||||
- logger.log: "OTA begin"
|
||||
@@ -1,9 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests for the adafruit_ble OTA platform and its nrf52 integration."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.adafruit_ble.ota import CONFIG_SCHEMA, _final_validate
|
||||
from esphome.components.nrf52 import _final_validate as nrf52_final_validate
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V6
|
||||
from esphome.components.zephyr.const import (
|
||||
BOOTLOADER_MCUBOOT,
|
||||
KEY_BOOTLOADER,
|
||||
KEY_ZEPHYR,
|
||||
)
|
||||
from esphome.config import Config, read_config
|
||||
from esphome.const import (
|
||||
CONF_ADVANCED,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_OTA_ROLLBACK,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_OTA,
|
||||
CONF_PLATFORM,
|
||||
CONF_SAFE_MODE,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
NRF52_CONFIG = {
|
||||
KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
|
||||
CONF_FRAMEWORK: {CONF_ADVANCED: {}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adafruit_ble_ota_full_config() -> Generator[None]:
|
||||
"""Full config as final validation sees it with adafruit_ble OTA configured."""
|
||||
full = Config()
|
||||
full[CONF_OTA] = [{CONF_PLATFORM: "adafruit_ble"}]
|
||||
full[CONF_SAFE_MODE] = {CONF_DISABLED: False}
|
||||
token = fv.full_config.set(full)
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_adafruit_ble_config_builds_ota_component(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("adafruit_ble.yaml"))
|
||||
assert "adafruit_ble::OTAComponent" in main_cpp
|
||||
|
||||
|
||||
def test_requires_zephyr_ble_server(
|
||||
component_config_path: Callable[[str], Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
CORE.config_path = component_config_path("adafruit_ble_without_server.yaml")
|
||||
assert read_config({}) is None
|
||||
assert "requires component zephyr_ble_server" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_requires_adafruit_bootloader() -> None:
|
||||
CORE.data[KEY_ZEPHYR] = {KEY_BOOTLOADER: BOOTLOADER_MCUBOOT}
|
||||
with pytest.raises(cv.Invalid, match="requires an Adafruit bootloader"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("server", "warned"),
|
||||
[
|
||||
({}, True),
|
||||
({"on_numeric_comparison_request": [{"then": []}]}, False),
|
||||
],
|
||||
ids=["no_pairing", "pairing"],
|
||||
)
|
||||
def test_warns_about_open_trigger_without_pairing(
|
||||
adafruit_ble_ota_full_config: None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
server: dict,
|
||||
warned: bool,
|
||||
) -> None:
|
||||
CORE.data[KEY_ZEPHYR] = {KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT_NRF52_SD140_V6}
|
||||
fv.full_config.get()["zephyr_ble_server"] = server
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate({})
|
||||
assert ("without pairing" in caplog.text) is warned
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["on_progress", "on_end", "on_error", "on_abort"])
|
||||
def test_rejects_triggers_that_never_fire(key: str) -> None:
|
||||
schema = CONFIG_SCHEMA.validators[0] # Skips the framework check
|
||||
with pytest.raises(cv.Invalid, match=f"'{key}' never fires"):
|
||||
schema({key: [{"then": []}]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"drop",
|
||||
[None, CONF_SAFE_MODE, CONF_OTA],
|
||||
ids=["ota_and_safe_mode", "without_safe_mode", "without_ota"],
|
||||
)
|
||||
def test_rollback_ignored_when_enabled_by_user(
|
||||
adafruit_ble_ota_full_config: None,
|
||||
drop: str | None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An explicit request without MCUboot is turned off with a warning, so older configs still validate."""
|
||||
if drop is not None:
|
||||
del fv.full_config.get()[drop]
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = {"adafruit_ble"}
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] = True
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is False
|
||||
assert "requires MCUboot" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integrations", "expected"),
|
||||
[({"adafruit_ble"}, False), ({"zephyr_mcumgr"}, True)],
|
||||
ids=["adafruit_ble", "mcumgr_second_stage"],
|
||||
)
|
||||
def test_rollback_default_follows_mcuboot(
|
||||
adafruit_ble_ota_full_config: None, integrations: set[str], expected: bool
|
||||
) -> None:
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = integrations
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is expected
|
||||
|
||||
|
||||
def test_rollback_default_off_quietly_without_safe_mode(
|
||||
adafruit_ble_ota_full_config: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
del fv.full_config.get()[CONF_SAFE_MODE]
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = {"adafruit_ble"}
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is False
|
||||
assert "rollback" not in caplog.text
|
||||
|
||||
|
||||
def test_safe_mode_keeps_the_ble_trigger(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Bluetooth and the trigger come up in safe mode; user automations do not."""
|
||||
main_cpp = generate_main(component_config_path("adafruit_ble_safe_mode.yaml"))
|
||||
guard = main_cpp.index("->should_enter_safe_mode(")
|
||||
for registration in (
|
||||
"App.register_component_(zephyr_ble_server_bleserver_id",
|
||||
"App.register_component_(adafruit_ble_otacomponent_id",
|
||||
):
|
||||
assert main_cpp.index(registration) < guard
|
||||
for automation in ("->add_passkey_callback(", "new(ota_otastarttrigger_id)"):
|
||||
assert main_cpp.index(automation) > guard
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: bk-bdk-newer
|
||||
platformio_options:
|
||||
custom_versions.beken-bdk: "3.0.80"
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: bk-bdk-too-old
|
||||
platformio_options:
|
||||
custom_versions.beken-bdk: "3.0.33"
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: bk-libretiny-too-old
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
framework:
|
||||
version: 1.13.0
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
bk72xx_ble:
|
||||
@@ -0,0 +1,53 @@
|
||||
"""bk72xx_ble refuses a beken-bdk override outside 3.0.78 and later 3.0.x releases:
|
||||
an older BDK can crash at boot or erase the bootloader with BLE compiled in, and
|
||||
LibreTiny only builds the 3.0 line."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.bk72xx_ble import check_bdk_version
|
||||
from esphome.config import read_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "3.0.78", "v3.0.78", "3.0.80"])
|
||||
def test_new_enough_versions_pass(value: str | None) -> None:
|
||||
check_bdk_version(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"3.0.33",
|
||||
"3.0.77",
|
||||
"3.1.0",
|
||||
"4.0.0",
|
||||
"2021.06.07",
|
||||
"main",
|
||||
"https://github.com/x/bdk#3.0.78",
|
||||
],
|
||||
)
|
||||
def test_other_versions_fail(value: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="3.0.78 or a newer 3.0.x"):
|
||||
check_bdk_version(value)
|
||||
|
||||
|
||||
def test_old_override_fails_validation(
|
||||
component_config_path: Callable[[str], Path],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
CORE.config_path = component_config_path("test_bdk_too_old.yaml")
|
||||
assert read_config({}) is None
|
||||
assert "3.0.78 or a newer 3.0.x" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_newer_override_builds(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_bdk_newer.yaml"))
|
||||
assert "bk72xx_ble::BK72xxBLE" in main_cpp
|
||||
assert CORE.platformio_options.get("custom_versions.beken-bdk") == "3.0.80"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""bk72xx_ble refuses LibreTiny releases older than 1.14.0: their WiFi sleep arms the
|
||||
Beken MCU sleep, which halts the device once the station stops while BLE runs."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.bk72xx_ble import check_libretiny_version
|
||||
from esphome.config import read_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["1.14.0", "1.14.1", "1.15.0", "2.0.0"])
|
||||
def test_new_enough_releases_pass(version: str) -> None:
|
||||
check_libretiny_version(cv.Version.parse(version))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["1.13.0", "1.12.2", "0.0.0"])
|
||||
def test_older_releases_fail(version: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="1.14.0 or newer"):
|
||||
check_libretiny_version(cv.Version.parse(version))
|
||||
|
||||
|
||||
def test_pinned_old_framework_fails_validation(
|
||||
component_config_path: Callable[[str], Path],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
CORE.config_path = component_config_path("test_libretiny_too_old.yaml")
|
||||
assert read_config({}) is None
|
||||
assert "1.14.0 or newer" in capsys.readouterr().out
|
||||
@@ -1,20 +1,16 @@
|
||||
"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not
|
||||
wake up once the station is stopped while the BLE controller runs."""
|
||||
"""bk72xx_ble no longer keeps WiFi power save off: LibreTiny 1.14.0 sleeps the radio
|
||||
between DTIM beacons only, so the Beken MCU sleep that halted the chip with BLE running
|
||||
is never armed (libretiny-eu/libretiny#414)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_power_save_mode_is_not_applied_with_ble(
|
||||
def test_power_save_mode_is_applied_with_ble(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_power_save.yaml"))
|
||||
|
||||
assert "bk72xx_ble::BK72xxBLE" in main_cpp
|
||||
assert "set_power_save_mode(" not in main_cpp
|
||||
assert "power_save_mode HIGH is not applied" in caplog.text
|
||||
assert "issues/18592" in caplog.text
|
||||
assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_HIGH)" in main_cpp
|
||||
@@ -1,48 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
- climate.control:
|
||||
id: cn105_ac
|
||||
target_temperature: 72°F
|
||||
- climate.control:
|
||||
id: friedrich_ac
|
||||
target_temperature: 20°C
|
||||
- climate.control:
|
||||
id: islandaire_ac
|
||||
target_temperature: 20°C
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
uart:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
baud_rate: 2400
|
||||
parity: EVEN
|
||||
|
||||
remote_transmitter:
|
||||
pin: GPIO5
|
||||
carrier_duty_percent: 50%
|
||||
|
||||
mitsubishi_cn105:
|
||||
id: hub
|
||||
use_fahrenheit: ${use_fahrenheit}
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: cn105_ac
|
||||
name: CN105
|
||||
visual:
|
||||
min_temperature: 60°F
|
||||
max_temperature: 30°C
|
||||
- platform: friedrich
|
||||
id: friedrich_ac
|
||||
name: Friedrich
|
||||
visual:
|
||||
min_temperature: 15°C
|
||||
- platform: islandaire
|
||||
id: islandaire_ac
|
||||
name: Islandaire
|
||||
visual:
|
||||
min_temperature: 15°C
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Tests for the climate visual temperature conversion."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.climate import (
|
||||
Climate,
|
||||
_final_validate,
|
||||
_visual_temperatures_in_entity_unit,
|
||||
climate_ns,
|
||||
climate_schema,
|
||||
visual_temperature_step,
|
||||
)
|
||||
from esphome.config import Config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
CONF_ID,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_NAME,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
)
|
||||
from esphome.core import CORE, ID, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_temperature_unit,
|
||||
entity_temperature_unit_of_id,
|
||||
setup_entity_temperature_unit,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
|
||||
def test_visual_temperature_step_scalar_applies_to_both() -> None:
|
||||
step = visual_temperature_step("1°F")
|
||||
assert step == {
|
||||
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
CONF_CURRENT_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
}
|
||||
|
||||
|
||||
def test_visual_temperatures_default_to_celsius() -> None:
|
||||
config = _visual_temperatures_in_entity_unit(
|
||||
{
|
||||
CONF_VISUAL: {
|
||||
CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT),
|
||||
CONF_MAX_TEMPERATURE: (30.0, None),
|
||||
CONF_TEMPERATURE_STEP: visual_temperature_step("1.8°F"),
|
||||
}
|
||||
}
|
||||
)
|
||||
visual = config[CONF_VISUAL]
|
||||
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
|
||||
assert visual[CONF_MAX_TEMPERATURE] == 30.0
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_visual_temperatures_follow_unit_of_measurement() -> None:
|
||||
config = _visual_temperatures_in_entity_unit(
|
||||
{
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT),
|
||||
CONF_VISUAL: {
|
||||
CONF_MIN_TEMPERATURE: (60.0, None),
|
||||
CONF_MAX_TEMPERATURE: (30.0, UNIT_CELSIUS),
|
||||
CONF_TEMPERATURE_STEP: {
|
||||
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
CONF_CURRENT_TEMPERATURE: (0.5, UNIT_CELSIUS),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
visual = config[CONF_VISUAL]
|
||||
assert visual[CONF_MIN_TEMPERATURE] == 60.0
|
||||
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(86.0)
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_CURRENT_TEMPERATURE] == pytest.approx(0.9)
|
||||
|
||||
|
||||
def test_only_a_validated_unit_is_the_entity_unit() -> None:
|
||||
# anova validates its own unit_of_measurement for the device; it is not the entity unit
|
||||
assert entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS
|
||||
assert (
|
||||
entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
|
||||
== UNIT_CELSIUS
|
||||
)
|
||||
assert (
|
||||
entity_temperature_unit(
|
||||
{CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT)}
|
||||
)
|
||||
== UNIT_FAHRENHEIT
|
||||
)
|
||||
|
||||
|
||||
def test_visual_temperatures_converted_once() -> None:
|
||||
config = {CONF_VISUAL: {CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT)}}
|
||||
_visual_temperatures_in_entity_unit(config)
|
||||
_visual_temperatures_in_entity_unit(config)
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_a_fixed_unit_keeps_visual_temperatures_in_it() -> None:
|
||||
# friedrich reports in °F through its traits; the unit is declared, not a user option
|
||||
fixed = climate_ns.class_("FixedUnitClimate", Climate)
|
||||
schema = climate_schema(fixed, fixed_temperature_unit=UNIT_FAHRENHEIT)
|
||||
config = schema({CONF_NAME: "AC", CONF_VISUAL: {CONF_TEMPERATURE_STEP: "1°F"}})
|
||||
assert entity_temperature_unit(config) == UNIT_FAHRENHEIT
|
||||
assert config[CONF_VISUAL][CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
|
||||
# Nothing is stored in the config, so a dumped config (without its generated id) validates again
|
||||
assert CONF_UNIT_OF_MEASUREMENT not in config
|
||||
again = schema(
|
||||
{key: value for key, value in config.items() if key != CONF_ID}
|
||||
| {CONF_NAME: "AC again"}
|
||||
)
|
||||
assert again[CONF_VISUAL] == config[CONF_VISUAL]
|
||||
with pytest.raises(cv.Invalid, match="unit_of_measurement"):
|
||||
schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"declared",
|
||||
[UNIT_FAHRENHEIT, lambda config, full: UNIT_FAHRENHEIT],
|
||||
ids=["fixed", "resolved"],
|
||||
)
|
||||
def test_a_subclass_reports_in_its_parents_declared_unit(
|
||||
declared: str | Callable[[dict, Config], str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An external platform built on a declared class (friedrich) keeps the parent's unit."""
|
||||
monkeypatch.setattr(entity_helpers, "_FIXED_TEMPERATURE_UNITS", {})
|
||||
parent = climate_ns.class_("DeclaredUnitClimate", Climate)
|
||||
climate_schema(parent, fixed_temperature_unit=declared)
|
||||
child = climate_ns.class_("ExternalClimate", parent)
|
||||
grandchild = climate_ns.class_("VendoredClimate", child)
|
||||
for class_ in (child, grandchild):
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=class_)}
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_FAHRENHEIT
|
||||
# The closest declaration wins
|
||||
climate_schema(child, fixed_temperature_unit=UNIT_KELVIN)
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=grandchild)}
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_KELVIN
|
||||
# An undeclared class still reports in Celsius
|
||||
unrelated = climate_ns.class_("UnrelatedClimate", Climate)
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=unrelated)}
|
||||
assert entity_temperature_unit(config) == UNIT_CELSIUS
|
||||
|
||||
|
||||
def test_a_platform_cannot_take_the_option_and_fix_the_unit() -> None:
|
||||
with pytest.raises(ValueError, match="not both"):
|
||||
climate_schema(
|
||||
Climate, temperature_unit=True, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
)
|
||||
|
||||
|
||||
def test_a_fixed_unit_is_not_passed_to_the_platform() -> None:
|
||||
calls: list[str] = []
|
||||
original_add = entity_helpers.add
|
||||
entity_helpers.add = lambda expression: calls.append(str(expression))
|
||||
try:
|
||||
var = MockObj("ac")
|
||||
fixed = climate_ns.class_("FixedUnitClimate", Climate)
|
||||
climate_schema(fixed, fixed_temperature_unit=UNIT_FAHRENHEIT)
|
||||
setup_entity_temperature_unit(
|
||||
var, {CONF_ID: ID("ac", is_declaration=True, type=fixed)}
|
||||
)
|
||||
assert calls == []
|
||||
setup_entity_temperature_unit(
|
||||
var, {CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit("°F")}
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].startswith("ac.set_temperature_unit(")
|
||||
assert calls[0].endswith("TemperatureUnit::FAHRENHEIT)")
|
||||
finally:
|
||||
entity_helpers.add = original_add
|
||||
|
||||
|
||||
def test_kelvin_is_rejected_with_mqtt() -> None:
|
||||
schema = climate_schema(Climate, temperature_unit=True)
|
||||
CORE.loaded_integrations.add("mqtt")
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="°C or °F"):
|
||||
schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_KELVIN})
|
||||
finally:
|
||||
CORE.loaded_integrations.discard("mqtt")
|
||||
config = schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_KELVIN})
|
||||
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KELVIN
|
||||
|
||||
|
||||
def test_action_temperatures_follow_the_target_entity_unit() -> None:
|
||||
ac = ID("ac", is_declaration=True, type="climate::Climate")
|
||||
full = Config()
|
||||
full["climate"] = [
|
||||
{CONF_ID: ac, CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT)}
|
||||
]
|
||||
full.declare_ids.append((ac, ["climate", 0, CONF_ID]))
|
||||
CORE.config = full
|
||||
|
||||
target = ID("ac", is_declaration=False)
|
||||
assert entity_temperature_unit_of_id(target) == UNIT_FAHRENHEIT
|
||||
assert temperature_in_entity_unit(
|
||||
{CONF_ID: target}, (20.0, UNIT_CELSIUS)
|
||||
).startswith("68.0")
|
||||
|
||||
|
||||
def test_a_resolved_unit_waits_for_the_full_config() -> None:
|
||||
resolved = climate_ns.class_("ResolvedUnitClimate", Climate)
|
||||
schema = climate_schema(
|
||||
resolved, fixed_temperature_unit=lambda config, full: UNIT_FAHRENHEIT
|
||||
)
|
||||
# The resolver needs the full config, so schema validation leaves the value as written
|
||||
config = schema({CONF_NAME: "AC", CONF_VISUAL: {CONF_MIN_TEMPERATURE: "15°C"}})
|
||||
assert entity_temperature_unit(config) is None
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == (15.0, UNIT_CELSIUS)
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_FAHRENHEIT
|
||||
_final_validate([config])
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(59.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("use_fahrenheit", "cn105_lines"),
|
||||
[
|
||||
(
|
||||
"true",
|
||||
(
|
||||
"cn105_ac->set_visual_min_temperature_override(60.0f);",
|
||||
"cn105_ac->set_visual_max_temperature_override(86.0f);",
|
||||
"apply_call.set_target_temperature(72.0f);",
|
||||
),
|
||||
),
|
||||
(
|
||||
"false",
|
||||
(
|
||||
"cn105_ac->set_visual_min_temperature_override(15.5555",
|
||||
"cn105_ac->set_visual_max_temperature_override(30.0f);",
|
||||
"apply_call.set_target_temperature(22.2222",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_platforms_reporting_in_fahrenheit_resolve_their_unit(
|
||||
use_fahrenheit: str,
|
||||
cn105_lines: tuple[str, ...],
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# mitsubishi_cn105 follows its hub's use_fahrenheit; friedrich and islandaire are always °F
|
||||
source = component_config_path("fahrenheit_platforms.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
config_path = tmp_path / "fahrenheit_platforms.yaml"
|
||||
config_path.write_text(
|
||||
source.replace("${use_fahrenheit}", use_fahrenheit), encoding="utf-8"
|
||||
)
|
||||
main_cpp = generate_main(config_path)
|
||||
for line in cn105_lines:
|
||||
assert line in main_cpp
|
||||
assert "friedrich_ac->set_visual_min_temperature_override(59.0f);" in main_cpp
|
||||
assert "islandaire_ac->set_visual_min_temperature_override(59.0f);" in main_cpp
|
||||
# friedrich and islandaire are each sent 20°C as 68°F
|
||||
assert main_cpp.count("apply_call.set_target_temperature(68.0f);") == 2
|
||||
@@ -1,17 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-sticky
|
||||
@@ -1,23 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
# Takes over the model's default enable pin
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: panel_power
|
||||
pin: GPIO47
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-sticky
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Tests for applying the default enable pins of epaper_spi models."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def _enable_pin_numbers(main_cpp: str) -> list[int]:
|
||||
"""Get the GPIO numbers passed to set_enable_pins() in the generated code."""
|
||||
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
if match is None:
|
||||
return []
|
||||
numbers = []
|
||||
for pin_id in match.group(1).split(", "):
|
||||
pin = re.search(rf"{pin_id}->set_pin\(::GPIO_NUM_(\d+)\);", main_cpp)
|
||||
assert pin is not None
|
||||
numbers.append(int(pin.group(1)))
|
||||
return numbers
|
||||
|
||||
|
||||
def test_default_enable_pin(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The model's enable pin is applied when it is not used elsewhere."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_default.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [47]
|
||||
|
||||
|
||||
def test_default_enable_pin_used_elsewhere(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A default enable pin used by a switch is left out instead of failing validation."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_used_elsewhere.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == []
|
||||
assert "panel_power->set_pin(" in main_cpp
|
||||
|
||||
|
||||
def test_user_enable_pins(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Enable pins given in the config are applied as given."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [25, 26]
|
||||
@@ -1,26 +0,0 @@
|
||||
esphome:
|
||||
name: it8951-enable-pins
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: 7
|
||||
mosi_pin: 9
|
||||
miso_pin: 8
|
||||
|
||||
# Takes over the model's default 1.8V logic supply enable pin
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: logic_supply
|
||||
pin: GPIO21
|
||||
|
||||
display:
|
||||
- platform: it8951
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-e1003
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for it8951 configuration validation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome.components.const import CONF_HOLD_STATE
|
||||
@@ -28,18 +26,3 @@ def test_reterminal_e1003_enable_pins_hold_state(
|
||||
enable_pins = config[CONF_ENABLE_PIN]
|
||||
assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11]
|
||||
assert all(pin[CONF_HOLD_STATE] for pin in enable_pins)
|
||||
|
||||
|
||||
def test_reterminal_e1003_enable_pin_used_elsewhere(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A default enable pin used by a switch is left out, and the other is still applied."""
|
||||
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
|
||||
|
||||
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
assert match is not None
|
||||
pin_ids = match.group(1).split(", ")
|
||||
assert len(pin_ids) == 1
|
||||
assert f"{pin_ids[0]}->set_pin(::GPIO_NUM_11);" in main_cpp
|
||||
assert "logic_supply->set_pin(" in main_cpp
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: bdk-bk7231n
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: bdk-bk7231t
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231t-qfn32-tuya
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user