mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 23:42:15 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400407b135 | ||
|
|
22b32b194a | ||
|
|
6dcd989a7f | ||
|
|
2016884210 | ||
|
|
d3f4f44c7b | ||
|
|
6df15be714 | ||
|
|
a03fe3c38e | ||
|
|
afc8c51f93 | ||
|
|
ef7ed69f9d | ||
|
|
ec32802662 | ||
|
|
433d8046b1 | ||
|
|
8ecb981656 | ||
|
|
8c990573d7 | ||
|
|
bdec0c1485 | ||
|
|
70e85d939d | ||
|
|
25b3c2820a | ||
|
|
ef53e675ff | ||
|
|
10b569491d | ||
|
|
71b1b58e8d | ||
|
|
4ab67d867f | ||
|
|
a2a46944ab | ||
|
|
537d1643cb | ||
|
|
07744bb53e | ||
|
|
c5d69ce22b | ||
|
|
21fb25a45f |
No files matched your search
@@ -16,6 +16,8 @@ esphome/components/a01nyub/* @MrSuicideParrot
|
|||||||
esphome/components/a02yyuw/* @TH-Braemer
|
esphome/components/a02yyuw/* @TH-Braemer
|
||||||
esphome/components/absolute_humidity/* @DAVe3283
|
esphome/components/absolute_humidity/* @DAVe3283
|
||||||
esphome/components/ac_dimmer/* @glmnet
|
esphome/components/ac_dimmer/* @glmnet
|
||||||
|
esphome/components/adafruit_ble/* @omersiar
|
||||||
|
esphome/components/adafruit_ble/ota/* @omersiar
|
||||||
esphome/components/adc/* @esphome/core
|
esphome/components/adc/* @esphome/core
|
||||||
esphome/components/adc128s102/* @DeerMaximum
|
esphome/components/adc128s102/* @DeerMaximum
|
||||||
esphome/components/addressable_light/* @justfalter
|
esphome/components/addressable_light/* @justfalter
|
||||||
@@ -86,6 +88,7 @@ esphome/components/bl0940/* @dan-s-github @tobias-
|
|||||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||||
|
esphome/components/ble_midi/* @bogdanr
|
||||||
esphome/components/ble_nus/* @tomaszduda23
|
esphome/components/ble_nus/* @tomaszduda23
|
||||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||||
|
|||||||
@@ -348,8 +348,9 @@ class ApplyField:
|
|||||||
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
|
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
|
``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``
|
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
|
||||||
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
|
is not the right spelling, or the value depends on another entity's config, such as the unit
|
||||||
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
|
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
|
||||||
|
``type_``. An absent key emits nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
conf_key: str | tuple[str, ...]
|
conf_key: str | tuple[str, ...]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
CODEOWNERS = ["@omersiar"]
|
||||||
|
DOMAIN = "adafruit_ble"
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""Host-side Adafruit nRF52 bootloader BLE OTA (Nordic legacy DFU).
|
||||||
|
|
||||||
|
The Adafruit bootloader only serves its DFU service while it is in DFU mode, so
|
||||||
|
an update has two steps: ask the running application to reboot into the
|
||||||
|
bootloader (the trigger service built into the ``adafruit_ble`` OTA component),
|
||||||
|
then push the DFU package to the bootloader itself.
|
||||||
|
|
||||||
|
Reference implementation of the protocol:
|
||||||
|
https://github.com/recrof/nrf_dfu_py/blob/main/dfu_lib.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from bleak import BleakClient, BleakScanner
|
||||||
|
from bleak.backends.device import BLEDevice
|
||||||
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
from bleak.exc import (
|
||||||
|
BleakDBusError,
|
||||||
|
BleakDeviceNotFoundError,
|
||||||
|
BleakError,
|
||||||
|
BleakGATTProtocolError,
|
||||||
|
BleakGATTProtocolErrorCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
from esphome.components.nrf52.ble_logger import MAC_ADDRESS_PATTERN
|
||||||
|
from esphome.core import EsphomeError
|
||||||
|
from esphome.espota2 import ProgressBar
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Nordic legacy DFU service, used by the Adafruit nRF52 bootloader.
|
||||||
|
DFU_SERVICE_UUID = "00001530-1212-efde-1523-785feabcd123"
|
||||||
|
DFU_CONTROL_POINT_UUID = "00001531-1212-efde-1523-785feabcd123"
|
||||||
|
DFU_PACKET_UUID = "00001532-1212-efde-1523-785feabcd123"
|
||||||
|
|
||||||
|
# ESPHome DFU trigger service, exposed by the application (ota_adafruit_ble.cpp).
|
||||||
|
# Kept distinct from the bootloader's DFU service so the two can be told apart.
|
||||||
|
TRIGGER_SERVICE_UUID = "e5b10001-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||||
|
TRIGGER_CHARACTERISTIC_UUID = "e5b10002-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||||
|
TRIGGER_MAGIC = b"DFU"
|
||||||
|
|
||||||
|
_OP_START_DFU = 0x01
|
||||||
|
_OP_INIT_DFU_PARAMS = 0x02
|
||||||
|
_OP_RECEIVE_FIRMWARE_IMAGE = 0x03
|
||||||
|
_OP_VALIDATE = 0x04
|
||||||
|
_OP_ACTIVATE_AND_RESET = 0x05
|
||||||
|
_OP_RESET = 0x06
|
||||||
|
_OP_PACKET_RECEIPT_NOTIF_REQ = 0x08
|
||||||
|
_OP_RESPONSE_CODE = 0x10
|
||||||
|
_OP_PACKET_RECEIPT_NOTIF = 0x11
|
||||||
|
|
||||||
|
_UPLOAD_MODE_APPLICATION = 0x04
|
||||||
|
_RESPONSE_SUCCESS = 0x01
|
||||||
|
_RESPONSE_INVALID_STATE = 0x02
|
||||||
|
_RESPONSE_STATUS_NAMES = {
|
||||||
|
0x02: "invalid state",
|
||||||
|
0x03: "not supported",
|
||||||
|
0x04: "data size exceeds limit",
|
||||||
|
0x05: "CRC error",
|
||||||
|
0x06: "operation failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The bootloader runs out of memory when more than 8 packets are unacknowledged.
|
||||||
|
_PRN = 8
|
||||||
|
# The bootloader needs a moment between START_DFU and the image size packet;
|
||||||
|
# sending the size too early leaves the start command unanswered.
|
||||||
|
_START_DELAY = 0.6
|
||||||
|
# A dropped link mid-update is usually transient, so the sequence is retried.
|
||||||
|
_ATTEMPTS = 3
|
||||||
|
_RETRY_DELAY = 3.0
|
||||||
|
# ATT payload cap; the negotiated MTU is normally smaller.
|
||||||
|
_MAX_CHUNK_SIZE = 244
|
||||||
|
# The bootloader rejects image sizes and data packets that are not whole words.
|
||||||
|
_WORD_SIZE = 4
|
||||||
|
_SCAN_TIMEOUT = 10.0
|
||||||
|
_RESPONSE_TIMEOUT = 30.0
|
||||||
|
# Starting the update erases flash and finishing it writes the last pages.
|
||||||
|
_LONG_RESPONSE_TIMEOUT = 60.0
|
||||||
|
_RECEIPT_TIMEOUT = 2.0
|
||||||
|
# Time the bootloader needs to start advertising after the DFU request.
|
||||||
|
_REBOOT_TIMEOUT = 20.0
|
||||||
|
# Without MAC addresses (macOS), the bootloader is the one DFU device that was
|
||||||
|
# not there before the request: the scan that lists those before it, and how
|
||||||
|
# long to keep watching for a second new one after the first appears.
|
||||||
|
_DFU_SNAPSHOT_TIMEOUT = 3.0
|
||||||
|
_NEW_DEVICE_SETTLE = 3.0
|
||||||
|
# macOS reports a device once per scan session, so a fresh session is needed to
|
||||||
|
# see whether the application still advertises.
|
||||||
|
_APP_CHECK_TIMEOUT = 3.0
|
||||||
|
# Error responses to the DFU mode request that pairing the device would fix.
|
||||||
|
_PAIRING_ERRORS = {
|
||||||
|
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHENTICATION,
|
||||||
|
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION,
|
||||||
|
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION,
|
||||||
|
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE,
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOTLOADER_HINT = (
|
||||||
|
"A device left in its bootloader by a failed upload advertises at its MAC "
|
||||||
|
"address + 1; pass that address with --device (on macOS, the UUID of the "
|
||||||
|
"DFU device)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DfuRejectedError(EsphomeError):
|
||||||
|
"""The bootloader answered with a failure status; retrying will not help."""
|
||||||
|
|
||||||
|
|
||||||
|
class _DfuStaleStateError(_DfuRejectedError):
|
||||||
|
"""START found the bootloader mid-update from an interrupted transfer.
|
||||||
|
|
||||||
|
The failed START resets the bootloader, so one more attempt can succeed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _rejected_error(request_op: int, step: str, status: int) -> _DfuRejectedError:
|
||||||
|
name = _RESPONSE_STATUS_NAMES.get(status, "unknown")
|
||||||
|
error = (
|
||||||
|
_DfuStaleStateError
|
||||||
|
if request_op == _OP_START_DFU and status == _RESPONSE_INVALID_STATE
|
||||||
|
else _DfuRejectedError
|
||||||
|
)
|
||||||
|
return error(
|
||||||
|
f"DFU {step} (opcode {request_op:#04x}) rejected by the bootloader "
|
||||||
|
f"with status {status:#04x} ({name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DfuSession:
|
||||||
|
"""Control point notifications and packet receipts of one DFU run."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._responses: asyncio.Queue[tuple[int, int]] = asyncio.Queue()
|
||||||
|
self._receipt = asyncio.Event()
|
||||||
|
self._rejection: tuple[int, int] | None = None
|
||||||
|
self.receipts = 0
|
||||||
|
|
||||||
|
def notification_handler(self, _sender: int, data: bytearray) -> None:
|
||||||
|
if not data:
|
||||||
|
_LOGGER.debug("DFU: ignoring an empty notification")
|
||||||
|
return
|
||||||
|
opcode = data[0]
|
||||||
|
if opcode == _OP_RESPONSE_CODE and len(data) >= 3:
|
||||||
|
self._responses.put_nowait((data[1], data[2]))
|
||||||
|
if data[2] != _RESPONSE_SUCCESS:
|
||||||
|
# Wakes a receipt wait, which no receipt would end after a rejection
|
||||||
|
self._rejection = (data[1], data[2])
|
||||||
|
self._receipt.set()
|
||||||
|
elif opcode == _OP_PACKET_RECEIPT_NOTIF:
|
||||||
|
self.receipts += 1
|
||||||
|
self._receipt.set()
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("DFU: ignoring notification %s", data.hex())
|
||||||
|
|
||||||
|
async def expect_success(
|
||||||
|
self, request_op: int, step: str, timeout: float = _RESPONSE_TIMEOUT
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
response_op, status = await asyncio.wait_for(self._responses.get(), timeout)
|
||||||
|
except TimeoutError as err:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"DFU timed out waiting for the response to opcode {request_op:#04x}"
|
||||||
|
) from err
|
||||||
|
if response_op != request_op:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"DFU got a response for opcode {response_op:#04x}, "
|
||||||
|
f"expected {request_op:#04x}"
|
||||||
|
)
|
||||||
|
if status != _RESPONSE_SUCCESS:
|
||||||
|
raise _rejected_error(request_op, step, status)
|
||||||
|
|
||||||
|
def raise_if_rejected(self, step: str) -> None:
|
||||||
|
"""Raise if the bootloader already answered a command with a failure."""
|
||||||
|
if self._rejection is not None:
|
||||||
|
request_op, status = self._rejection
|
||||||
|
raise _rejected_error(request_op, step, status)
|
||||||
|
|
||||||
|
async def wait_for_receipts(self, count: int, step: str) -> None:
|
||||||
|
"""Wait until ``count`` packet receipts have arrived in total."""
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(_RECEIPT_TIMEOUT):
|
||||||
|
while self.receipts < count:
|
||||||
|
self.raise_if_rejected(step)
|
||||||
|
# Cleared only while the count is short, so no receipt is lost
|
||||||
|
self._receipt.clear()
|
||||||
|
await self._receipt.wait()
|
||||||
|
except TimeoutError as err:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"DFU timed out waiting for packet receipt {count}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
|
||||||
|
def _service_uuids(advertisement) -> set[str]:
|
||||||
|
return {uuid.lower() for uuid in (advertisement.service_uuids or [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _next_address(address: str) -> str | None:
|
||||||
|
"""The Adafruit bootloader can come up one address above the application."""
|
||||||
|
if not MAC_ADDRESS_PATTERN.match(address):
|
||||||
|
return None
|
||||||
|
prefix, last = address[:-2], int(address[-2:], 16)
|
||||||
|
return f"{prefix}{((last + 1) & 0xFF):02X}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _dfu_devices() -> set[str]:
|
||||||
|
"""Addresses of the devices advertising the DFU service right now."""
|
||||||
|
found = await BleakScanner.discover(timeout=_DFU_SNAPSHOT_TIMEOUT, return_adv=True)
|
||||||
|
return {
|
||||||
|
device.address
|
||||||
|
for device, advertisement in found.values()
|
||||||
|
if DFU_SERVICE_UUID in _service_uuids(advertisement)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DfuPackage:
|
||||||
|
"""The application image and init packet of an Adafruit DFU package."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
image: bytes
|
||||||
|
init_packet: bytes
|
||||||
|
|
||||||
|
|
||||||
|
def load_firmware(firmware: Path) -> DfuPackage:
|
||||||
|
"""Read and check a DFU package, before the device is asked to reboot."""
|
||||||
|
if not firmware.is_file():
|
||||||
|
raise EsphomeError(
|
||||||
|
f"DFU package not found: {firmware}. Compile the firmware first."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return _read_package(firmware)
|
||||||
|
except (zipfile.BadZipFile, KeyError, ValueError) as err:
|
||||||
|
raise EsphomeError(f"Invalid DFU package {firmware}: {err}") from err
|
||||||
|
|
||||||
|
|
||||||
|
def _read_package(firmware: Path) -> DfuPackage:
|
||||||
|
with zipfile.ZipFile(firmware) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
if "manifest.json" in names:
|
||||||
|
bin_file, dat_file = _manifest_files(
|
||||||
|
firmware, json.loads(archive.read("manifest.json"))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bins = [name for name in names if name.endswith(".bin")]
|
||||||
|
dats = [name for name in names if name.endswith(".dat")]
|
||||||
|
if len(bins) > 1 or len(dats) > 1:
|
||||||
|
raise ValueError("more than one .bin or .dat file and no manifest")
|
||||||
|
if not bins or not dats:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Could not find the application image in {firmware}"
|
||||||
|
)
|
||||||
|
bin_file, dat_file = bins[0], dats[0]
|
||||||
|
image = archive.read(bin_file)
|
||||||
|
init_packet = archive.read(dat_file)
|
||||||
|
if not image:
|
||||||
|
raise EsphomeError(f"Empty application image in {firmware}")
|
||||||
|
if len(image) % _WORD_SIZE:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"The application image in {firmware} is {len(image)} bytes, "
|
||||||
|
f"which the bootloader rejects as it is not a multiple of {_WORD_SIZE}"
|
||||||
|
)
|
||||||
|
return DfuPackage(firmware.name, image, init_packet)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_files(firmware: Path, manifest: object) -> tuple[str, str]:
|
||||||
|
"""The image and init packet names of the application in ``manifest``."""
|
||||||
|
if isinstance(manifest, dict):
|
||||||
|
manifest = manifest.get("manifest", manifest)
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise ValueError("manifest.json is not a JSON object")
|
||||||
|
application = manifest.get("application")
|
||||||
|
if application is None:
|
||||||
|
raise EsphomeError(f"{firmware} is not an application update package")
|
||||||
|
if not isinstance(application, dict):
|
||||||
|
raise ValueError("the manifest application entry is not a JSON object")
|
||||||
|
bin_file = application.get("bin_file")
|
||||||
|
dat_file = application.get("dat_file")
|
||||||
|
if not isinstance(bin_file, str) or not isinstance(dat_file, str):
|
||||||
|
raise ValueError("the manifest names no bin_file and dat_file")
|
||||||
|
return bin_file, dat_file
|
||||||
|
|
||||||
|
|
||||||
|
class _DeviceNotFoundError(EsphomeError):
|
||||||
|
"""The device did not answer at the address it was asked for."""
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_not_paired(address: str, err: Exception) -> None:
|
||||||
|
if isinstance(err, BleakDBusError) and "NotPermitted" in err.dbus_error:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Cannot connect to {address}: Make sure the device is paired."
|
||||||
|
) from err
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _connect(address: str) -> AsyncIterator[BleakClient]:
|
||||||
|
client = BleakClient(address, timeout=_SCAN_TIMEOUT)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
except BleakDeviceNotFoundError as err:
|
||||||
|
raise _DeviceNotFoundError(f"Device {address} was not found") from err
|
||||||
|
except (BleakError, OSError) as err:
|
||||||
|
_raise_if_not_paired(address, err)
|
||||||
|
raise EsphomeError(f"Could not connect to {address}: {err}") from err
|
||||||
|
try:
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
async def enter_dfu_mode(address: str) -> str:
|
||||||
|
"""Return the address of the bootloader in DFU mode.
|
||||||
|
|
||||||
|
A running application is first asked to reboot into the bootloader. A
|
||||||
|
device that is not found may still be in its bootloader from a failed
|
||||||
|
upload, which can advertise one address above the application.
|
||||||
|
"""
|
||||||
|
request_error: Exception | None = None
|
||||||
|
known_dfu: set[str] | None = None
|
||||||
|
try:
|
||||||
|
async with _connect(address) as client:
|
||||||
|
services = {str(service.uuid).lower() for service in client.services}
|
||||||
|
if DFU_SERVICE_UUID in services:
|
||||||
|
return address
|
||||||
|
if TRIGGER_SERVICE_UUID not in services:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"{address} does not offer an OTA service. Put the device "
|
||||||
|
"into DFU mode and try again."
|
||||||
|
)
|
||||||
|
if not MAC_ADDRESS_PATTERN.match(address):
|
||||||
|
# macOS hides MAC addresses and gives the bootloader its own UUID
|
||||||
|
known_dfu = await _dfu_devices()
|
||||||
|
_LOGGER.info("Requesting DFU mode from %s...", address)
|
||||||
|
try:
|
||||||
|
await client.write_gatt_char(
|
||||||
|
TRIGGER_CHARACTERISTIC_UUID, TRIGGER_MAGIC, response=True
|
||||||
|
)
|
||||||
|
except (BleakError, OSError) as err:
|
||||||
|
_raise_if_not_paired(address, err)
|
||||||
|
# The application reboots as soon as the write is handled, so a
|
||||||
|
# disconnect while waiting for the response is expected. Windows
|
||||||
|
# reports that as a cancelled operation (OSError), not a BleakError.
|
||||||
|
# The error is only reported if the bootloader then does not show
|
||||||
|
# up, except an error response (find_dfu_bootloader).
|
||||||
|
_LOGGER.debug("DFU mode request ended with %s", err)
|
||||||
|
request_error = err
|
||||||
|
except _DeviceNotFoundError:
|
||||||
|
_LOGGER.info("%s was not found, looking for its DFU bootloader", address)
|
||||||
|
return await find_dfu_bootloader(address, request_error, known_dfu)
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_new_dfu_devices(
|
||||||
|
known_dfu: set[str], app_address: str
|
||||||
|
) -> tuple[list[str], bool]:
|
||||||
|
"""Scan for DFU devices that are not in ``known_dfu``.
|
||||||
|
|
||||||
|
Also reports whether ``app_address`` still advertised after one appeared: an application that really restarted
|
||||||
|
into its bootloader stops advertising, so a new DFU device seen while it keeps going belongs to another board.
|
||||||
|
"""
|
||||||
|
known = {address.lower() for address in known_dfu}
|
||||||
|
app = app_address.lower()
|
||||||
|
new: dict[str, str] = {}
|
||||||
|
appeared = asyncio.Event()
|
||||||
|
app_still_advertising = False
|
||||||
|
|
||||||
|
def _on_advertisement(device: BLEDevice, advertisement: AdvertisementData) -> None:
|
||||||
|
nonlocal app_still_advertising
|
||||||
|
key = device.address.lower()
|
||||||
|
if key == app:
|
||||||
|
app_still_advertising = app_still_advertising or appeared.is_set()
|
||||||
|
return
|
||||||
|
if DFU_SERVICE_UUID in _service_uuids(advertisement) and key not in known:
|
||||||
|
new.setdefault(key, device.address)
|
||||||
|
appeared.set()
|
||||||
|
|
||||||
|
async with BleakScanner(detection_callback=_on_advertisement):
|
||||||
|
with suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(appeared.wait(), _REBOOT_TIMEOUT)
|
||||||
|
if appeared.is_set():
|
||||||
|
await asyncio.sleep(_NEW_DEVICE_SETTLE)
|
||||||
|
return list(new.values()), app_still_advertising
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_advertising(address: str) -> bool:
|
||||||
|
"""Whether ``address`` shows up in a fresh scan session."""
|
||||||
|
wanted = address.lower()
|
||||||
|
device = await BleakScanner.find_device_by_filter(
|
||||||
|
lambda device, _advertisement: device.address.lower() == wanted,
|
||||||
|
timeout=_APP_CHECK_TIMEOUT,
|
||||||
|
)
|
||||||
|
return device is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_if_request_rejected(address: str, request_error: Exception | None) -> None:
|
||||||
|
"""Raise if the application answered the DFU mode request with an error.
|
||||||
|
|
||||||
|
An error response comes from the running application, so it did not
|
||||||
|
restart. A dropped link stays acceptable, as the reboot can look like that.
|
||||||
|
"""
|
||||||
|
if not isinstance(request_error, BleakGATTProtocolError):
|
||||||
|
return
|
||||||
|
message = f"{address} rejected the DFU mode request: {request_error.args[-1]}."
|
||||||
|
if request_error.code in _PAIRING_ERRORS:
|
||||||
|
message += " Make sure the device is paired."
|
||||||
|
raise EsphomeError(message) from request_error
|
||||||
|
|
||||||
|
|
||||||
|
async def find_dfu_bootloader(
|
||||||
|
address: str,
|
||||||
|
request_error: Exception | None = None,
|
||||||
|
known_dfu: set[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Scan for the bootloader of the device that was asked to enter DFU mode.
|
||||||
|
|
||||||
|
Only ``address`` and the address above it are accepted, so a different
|
||||||
|
device that happens to be in DFU mode nearby is never flashed. With
|
||||||
|
``known_dfu`` (the DFU devices seen before the request, for hosts without
|
||||||
|
MAC addresses) the one DFU device that is new is accepted instead, and only
|
||||||
|
if exactly one appeared and the application no longer advertises. A request
|
||||||
|
the application rejected fails at once, without scanning.
|
||||||
|
"""
|
||||||
|
_raise_if_request_rejected(address, request_error)
|
||||||
|
_LOGGER.info("Scanning for the DFU bootloader...")
|
||||||
|
if known_dfu is not None:
|
||||||
|
new, app_still_advertising = await _find_new_dfu_devices(known_dfu, address)
|
||||||
|
if len(new) == 1 and not app_still_advertising:
|
||||||
|
app_still_advertising = await _is_advertising(address)
|
||||||
|
if not app_still_advertising:
|
||||||
|
return new[0]
|
||||||
|
if app_still_advertising:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s kept advertising, so it did not restart into its bootloader",
|
||||||
|
address,
|
||||||
|
)
|
||||||
|
raise _not_found_error(address, request_error, known_dfu | set(new))
|
||||||
|
wanted = {address.lower()}
|
||||||
|
if (next_address := _next_address(address)) is not None:
|
||||||
|
wanted.add(next_address.lower())
|
||||||
|
others: set[str] = set()
|
||||||
|
|
||||||
|
def _is_bootloader(device: BLEDevice, advertisement: AdvertisementData) -> bool:
|
||||||
|
if DFU_SERVICE_UUID not in _service_uuids(advertisement):
|
||||||
|
return False
|
||||||
|
if device.address.lower() in wanted:
|
||||||
|
return True
|
||||||
|
others.add(device.address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
device = await BleakScanner.find_device_by_filter(
|
||||||
|
_is_bootloader, timeout=_REBOOT_TIMEOUT
|
||||||
|
)
|
||||||
|
if device is not None:
|
||||||
|
return device.address
|
||||||
|
raise _not_found_error(address, request_error, others)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_found_error(
|
||||||
|
address: str, request_error: Exception | None, others: set[str]
|
||||||
|
) -> EsphomeError:
|
||||||
|
message = f"The DFU bootloader of {address} was not found."
|
||||||
|
if request_error is not None:
|
||||||
|
message += f" The DFU mode request failed: {request_error}."
|
||||||
|
if others:
|
||||||
|
message += f" Other devices in DFU mode: {', '.join(sorted(others))}."
|
||||||
|
return EsphomeError(f"{message} {BOOTLOADER_HINT}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_control(client: BleakClient, op: int, *payload: int) -> None:
|
||||||
|
await client.write_gatt_char(
|
||||||
|
DFU_CONTROL_POINT_UUID, bytes([op, *payload]), response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_firmware(
|
||||||
|
client: BleakClient, session: _DfuSession, image: bytes
|
||||||
|
) -> None:
|
||||||
|
# Every data packet must be whole words; the image size is too, so the last
|
||||||
|
# packet is as well.
|
||||||
|
chunk_size = max(20, min(client.mtu_size - 3, _MAX_CHUNK_SIZE))
|
||||||
|
chunk_size -= chunk_size % _WORD_SIZE
|
||||||
|
_LOGGER.debug("DFU uses %d byte chunks for %d bytes", chunk_size, len(image))
|
||||||
|
progress = ProgressBar("Uploading")
|
||||||
|
progress.update(0)
|
||||||
|
try:
|
||||||
|
for packet, offset in enumerate(range(0, len(image), chunk_size), start=1):
|
||||||
|
session.raise_if_rejected("upload")
|
||||||
|
chunk = image[offset : offset + chunk_size]
|
||||||
|
await client.write_gatt_char(DFU_PACKET_UUID, chunk, response=False)
|
||||||
|
progress.update((offset + len(chunk)) / len(image))
|
||||||
|
# The packet that completes the image is answered with the
|
||||||
|
# RECEIVE_FIRMWARE_IMAGE response instead of a receipt.
|
||||||
|
if packet % _PRN == 0 and offset + len(chunk) < len(image):
|
||||||
|
await session.wait_for_receipts(packet // _PRN, "upload")
|
||||||
|
finally:
|
||||||
|
progress.done()
|
||||||
|
|
||||||
|
|
||||||
|
async def _perform_update(
|
||||||
|
client: BleakClient, image: bytes, init_packet: bytes
|
||||||
|
) -> None:
|
||||||
|
session = _DfuSession()
|
||||||
|
await client.start_notify(DFU_CONTROL_POINT_UUID, session.notification_handler)
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("DFU: starting the update")
|
||||||
|
await _write_control(client, _OP_START_DFU, _UPLOAD_MODE_APPLICATION)
|
||||||
|
await asyncio.sleep(_START_DELAY)
|
||||||
|
await client.write_gatt_char(
|
||||||
|
DFU_PACKET_UUID, struct.pack("<III", 0, 0, len(image)), response=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.expect_success(
|
||||||
|
_OP_START_DFU, "start", timeout=_LONG_RESPONSE_TIMEOUT
|
||||||
|
)
|
||||||
|
except EsphomeError:
|
||||||
|
with suppress(BleakError, OSError):
|
||||||
|
await _write_control(client, _OP_RESET)
|
||||||
|
raise
|
||||||
|
|
||||||
|
_LOGGER.debug("DFU: sending the init packet")
|
||||||
|
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x00)
|
||||||
|
await client.write_gatt_char(DFU_PACKET_UUID, init_packet, response=False)
|
||||||
|
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x01)
|
||||||
|
await session.expect_success(_OP_INIT_DFU_PARAMS, "init packet")
|
||||||
|
|
||||||
|
await _write_control(
|
||||||
|
client, _OP_PACKET_RECEIPT_NOTIF_REQ, *struct.pack("<H", _PRN)
|
||||||
|
)
|
||||||
|
_LOGGER.debug("DFU: streaming the image")
|
||||||
|
await _write_control(client, _OP_RECEIVE_FIRMWARE_IMAGE)
|
||||||
|
await _stream_firmware(client, session, image)
|
||||||
|
await session.expect_success(
|
||||||
|
_OP_RECEIVE_FIRMWARE_IMAGE, "upload", timeout=_LONG_RESPONSE_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
await _write_control(client, _OP_VALIDATE)
|
||||||
|
await session.expect_success(_OP_VALIDATE, "validation")
|
||||||
|
|
||||||
|
# The bootloader resets as soon as it handles this, so the link drops
|
||||||
|
# before the write can be acknowledged (an OSError on Windows). An error
|
||||||
|
# response means it answered and refused.
|
||||||
|
try:
|
||||||
|
await _write_control(client, _OP_ACTIVATE_AND_RESET)
|
||||||
|
except BleakGATTProtocolError as err:
|
||||||
|
raise _DfuRejectedError(
|
||||||
|
f"DFU activation rejected by the bootloader: {err}"
|
||||||
|
) from err
|
||||||
|
except (BleakError, OSError) as err:
|
||||||
|
_LOGGER.debug("DFU activation ended with %s", err)
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
await client.stop_notify(DFU_CONTROL_POINT_UUID)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dfu_attempt(address: str, image: bytes, init_packet: bytes) -> None:
|
||||||
|
"""Run the whole DFU sequence once."""
|
||||||
|
async with _connect(address) as client:
|
||||||
|
try:
|
||||||
|
await _perform_update(client, image, init_packet)
|
||||||
|
except (BleakError, OSError) as err:
|
||||||
|
raise EsphomeError(f"DFU failed: {err}") from err
|
||||||
|
|
||||||
|
|
||||||
|
async def dfu_upload(address: str, package: DfuPackage) -> None:
|
||||||
|
"""Flash ``package`` (from ``load_firmware``) to ``address``."""
|
||||||
|
stale_state_retried = False
|
||||||
|
for attempt in range(1, _ATTEMPTS + 1):
|
||||||
|
_LOGGER.info(
|
||||||
|
"Uploading %s to %s (%d bytes)%s...",
|
||||||
|
package.name,
|
||||||
|
address,
|
||||||
|
len(package.image),
|
||||||
|
f", attempt {attempt}/{_ATTEMPTS}" if attempt > 1 else "",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await _dfu_attempt(address, package.image, package.init_packet)
|
||||||
|
except _DfuStaleStateError as err:
|
||||||
|
if stale_state_retried or attempt == _ATTEMPTS:
|
||||||
|
raise
|
||||||
|
stale_state_retried = True
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s; it was left mid-update and has been reset, retrying", err
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_RETRY_DELAY)
|
||||||
|
except _DfuRejectedError:
|
||||||
|
raise
|
||||||
|
except EsphomeError as err:
|
||||||
|
if attempt == _ATTEMPTS:
|
||||||
|
raise
|
||||||
|
_LOGGER.warning("DFU attempt %d/%d failed: %s", attempt, _ATTEMPTS, err)
|
||||||
|
await asyncio.sleep(_RETRY_DELAY)
|
||||||
|
else:
|
||||||
|
_LOGGER.info("DFU finished; the device restarts into the new firmware")
|
||||||
|
return
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import esphome.codegen as cg
|
||||||
|
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||||
|
from esphome.components.ota import (
|
||||||
|
BASE_OTA_SCHEMA,
|
||||||
|
CONF_ON_ABORT,
|
||||||
|
CONF_ON_END,
|
||||||
|
CONF_ON_PROGRESS,
|
||||||
|
OTAComponent,
|
||||||
|
ota_to_code,
|
||||||
|
)
|
||||||
|
from esphome.components.zephyr import zephyr_data
|
||||||
|
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||||
|
from esphome.components.zephyr_ble_server import (
|
||||||
|
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||||
|
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
||||||
|
)
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||||
|
from esphome.core import CoroPriority, coroutine_with_priority
|
||||||
|
import esphome.final_validate as fv
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CODEOWNERS = ["@omersiar"]
|
||||||
|
DEPENDENCIES = ["zephyr", "zephyr_ble_server"]
|
||||||
|
|
||||||
|
AdafruitBLEOTAComponent = cg.esphome_ns.namespace("adafruit_ble").class_(
|
||||||
|
"OTAComponent", OTAComponent
|
||||||
|
)
|
||||||
|
|
||||||
|
# The device reboots into the bootloader before the transfer starts, so only
|
||||||
|
# on_begin and on_state_change can fire.
|
||||||
|
_UNSUPPORTED_TRIGGERS = (CONF_ON_PROGRESS, CONF_ON_END, CONF_ON_ERROR, CONF_ON_ABORT)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.All(
|
||||||
|
BASE_OTA_SCHEMA.extend(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.declare_id(AdafruitBLEOTAComponent),
|
||||||
|
**{
|
||||||
|
cv.Optional(key): cv.invalid(
|
||||||
|
f"'{key}' never fires with Adafruit BLE OTA: the bootloader "
|
||||||
|
"runs the update after the device restarts into it"
|
||||||
|
)
|
||||||
|
for key in _UNSUPPORTED_TRIGGERS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).extend(cv.COMPONENT_SCHEMA),
|
||||||
|
cv.only_with_framework(Framework.ZEPHYR),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _final_validate(config: ConfigType) -> None:
|
||||||
|
# Every Adafruit bootloader variant is named adafruit*
|
||||||
|
bootloader = zephyr_data().get(KEY_BOOTLOADER, "")
|
||||||
|
if not bootloader.startswith(BOOTLOADER_ADAFRUIT):
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Adafruit BLE OTA requires an Adafruit bootloader, but '{bootloader}' "
|
||||||
|
"is configured. Set 'bootloader:' to an Adafruit one."
|
||||||
|
)
|
||||||
|
server = fv.full_config.get().get(ZEPHYR_BLE_SERVER_DOMAIN) or {}
|
||||||
|
if CONF_ON_NUMERIC_COMPARISON_REQUEST not in server:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Adafruit BLE OTA without pairing lets any nearby device restart this "
|
||||||
|
"one into the bootloader; add '%s' to '%s' to require pairing",
|
||||||
|
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||||
|
ZEPHYR_BLE_SERVER_DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||||
|
|
||||||
|
|
||||||
|
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||||
|
async def to_code(config: ConfigType) -> None:
|
||||||
|
# The Adafruit bootloader serves the update itself; the component provides
|
||||||
|
# the BLE service that asks the application to restart into it.
|
||||||
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
|
# Registered before ota_to_code waits past safe_mode, so safe mode keeps the trigger
|
||||||
|
await cg.register_component(var, config)
|
||||||
|
await ota_to_code(var, config)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#include "ota_adafruit_ble.h"
|
||||||
|
|
||||||
|
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||||
|
#include "esphome/core/application.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <hal/nrf_power.h>
|
||||||
|
#include <zephyr/bluetooth/gatt.h>
|
||||||
|
|
||||||
|
namespace esphome::adafruit_ble {
|
||||||
|
|
||||||
|
ESPHOME_LOG_TAG(TAG, "adafruit_ble");
|
||||||
|
|
||||||
|
// Written by the upload tool (dfu.py) to ask for a restart into the bootloader,
|
||||||
|
// which then serves the DFU transfer. The bootloader only advertises its own DFU
|
||||||
|
// service while it is in DFU mode, so this service is what identifies the
|
||||||
|
// running application.
|
||||||
|
static constexpr uint8_t DFU_TRIGGER_MAGIC[] = {'D', 'F', 'U'};
|
||||||
|
// GPREGRET value the Adafruit nRF52 bootloader reads to enter OTA (BLE DFU) mode
|
||||||
|
static constexpr uint8_t DFU_GPREGRET_OTA = 0xA8;
|
||||||
|
|
||||||
|
// Matches the MCUmgr BLE transport default: a paired (authenticated) link once pairing is enabled
|
||||||
|
#ifdef CONFIG_BT_SMP
|
||||||
|
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE_AUTHEN;
|
||||||
|
#else
|
||||||
|
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|
||||||
|
static ssize_t dfu_trigger_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len,
|
||||||
|
uint16_t offset, uint8_t flags) {
|
||||||
|
if (offset != 0) {
|
||||||
|
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
|
||||||
|
}
|
||||||
|
if (len != sizeof(DFU_TRIGGER_MAGIC) || memcmp(buf, DFU_TRIGGER_MAGIC, sizeof(DFU_TRIGGER_MAGIC)) != 0) {
|
||||||
|
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
global_ota_component->request_dfu_mode();
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered by bt_enable(), which zephyr_ble_server calls during its setup.
|
||||||
|
BT_GATT_SERVICE_DEFINE( // NOLINT
|
||||||
|
dfu_trigger_svc,
|
||||||
|
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10001, 0x9c3a, 0x4f5d, 0x8a1b,
|
||||||
|
0x2c3d4e5f6071))),
|
||||||
|
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10002, 0x9c3a, 0x4f5d, 0x8a1b, 0x2c3d4e5f6071)),
|
||||||
|
BT_GATT_CHRC_WRITE, DFU_TRIGGER_PERM, nullptr, dfu_trigger_write, nullptr));
|
||||||
|
|
||||||
|
OTAComponent::OTAComponent() { global_ota_component = this; }
|
||||||
|
|
||||||
|
void OTAComponent::request_dfu_mode() {
|
||||||
|
// Restart from the main loop, so the OTA listeners run there and the write is answered first
|
||||||
|
this->defer([this]() {
|
||||||
|
ESP_LOGI(TAG, "DFU mode requested, restarting into the bootloader");
|
||||||
|
#ifdef USE_OTA_STATE_LISTENER
|
||||||
|
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||||
|
#endif
|
||||||
|
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address
|
||||||
|
NRF_POWER->GPREGRET = DFU_GPREGRET_OTA;
|
||||||
|
App.reboot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void OTAComponent::dump_config() {
|
||||||
|
ESP_LOGCONFIG(TAG, "Over-The-Air Updates:\n"
|
||||||
|
" bootloader: Adafruit nRF52 (BLE DFU)");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::adafruit_ble
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "esphome/core/defines.h"
|
||||||
|
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||||
|
#include "esphome/components/ota/ota_backend.h"
|
||||||
|
|
||||||
|
namespace esphome::adafruit_ble {
|
||||||
|
|
||||||
|
// The update itself is served by the Adafruit bootloader; this component only
|
||||||
|
// hosts the BLE trigger service defined in ota_adafruit_ble.cpp.
|
||||||
|
class OTAComponent : public ota::OTAComponent {
|
||||||
|
public:
|
||||||
|
OTAComponent();
|
||||||
|
void dump_config() override;
|
||||||
|
// Called from the Bluetooth thread when the trigger is written
|
||||||
|
void request_dfu_mode();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::adafruit_ble
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from esphome import automation
|
||||||
|
import esphome.codegen as cg
|
||||||
|
from esphome.components import ble_client
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import (
|
||||||
|
CONF_CHANNEL,
|
||||||
|
CONF_DATA,
|
||||||
|
CONF_ON_CONNECT,
|
||||||
|
CONF_ON_DISCONNECT,
|
||||||
|
CONF_ON_MESSAGE,
|
||||||
|
CONF_PAYLOAD,
|
||||||
|
CONF_VALUE,
|
||||||
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
CODEOWNERS = ["@bogdanr"]
|
||||||
|
DEPENDENCIES = ["ble_client"]
|
||||||
|
DOMAIN = "ble_midi"
|
||||||
|
MULTI_CONF = True
|
||||||
|
|
||||||
|
CONF_CONTROL_NUMBER = "control_number"
|
||||||
|
CONF_MAX_SYSEX_SIZE = "max_sysex_size"
|
||||||
|
CONF_NOTE = "note"
|
||||||
|
CONF_ON_CONTROL_CHANGE = "on_control_change"
|
||||||
|
CONF_ON_NOTE_OFF = "on_note_off"
|
||||||
|
CONF_ON_NOTE_ON = "on_note_on"
|
||||||
|
CONF_ON_PITCH_BEND = "on_pitch_bend"
|
||||||
|
CONF_ON_PROGRAM_CHANGE = "on_program_change"
|
||||||
|
CONF_ON_SYSEX = "on_sysex"
|
||||||
|
CONF_PAIR = "pair"
|
||||||
|
CONF_PROGRAM = "program"
|
||||||
|
CONF_SYSEX_BUFFER_ID = "sysex_buffer_id"
|
||||||
|
CONF_VELOCITY = "velocity"
|
||||||
|
|
||||||
|
ble_midi_ns = cg.esphome_ns.namespace("ble_midi")
|
||||||
|
BLEMidi = ble_midi_ns.class_("BLEMidi", cg.Component, ble_client.BLEClientNode)
|
||||||
|
# The message bytes as a view: data.size() and data[i] work, and nothing is copied
|
||||||
|
ByteSpan = cg.std_span.template(cg.uint8.operator("const"))
|
||||||
|
|
||||||
|
# MIDI data bytes are 7-bit and channels are 4-bit, matching what goes on the wire and what the triggers report.
|
||||||
|
# Channel 0 is the channel that MIDI hardware usually labels "1".
|
||||||
|
midi_data_byte = cv.int_range(min=0, max=127)
|
||||||
|
midi_channel = cv.int_range(min=0, max=15)
|
||||||
|
|
||||||
|
_NOTE_ARGS = [
|
||||||
|
(cg.uint8, CONF_NOTE),
|
||||||
|
(cg.uint8, CONF_VELOCITY),
|
||||||
|
(cg.uint8, CONF_CHANNEL),
|
||||||
|
]
|
||||||
|
_BYTES_ARGS = [(ByteSpan, CONF_DATA)]
|
||||||
|
# data points into a notification or a reused buffer, so these automations cannot wait
|
||||||
|
_BYTES_TRIGGERS = (CONF_ON_SYSEX, CONF_ON_MESSAGE)
|
||||||
|
_CALLBACK_AUTOMATIONS = (
|
||||||
|
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||||
|
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_NOTE_ON, "add_on_note_on_callback", _NOTE_ARGS
|
||||||
|
),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_NOTE_OFF, "add_on_note_off_callback", _NOTE_ARGS
|
||||||
|
),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_CONTROL_CHANGE,
|
||||||
|
"add_on_control_change_callback",
|
||||||
|
[
|
||||||
|
(cg.uint8, CONF_CONTROL_NUMBER),
|
||||||
|
(cg.uint8, CONF_VALUE),
|
||||||
|
(cg.uint8, CONF_CHANNEL),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_PROGRAM_CHANGE,
|
||||||
|
"add_on_program_change_callback",
|
||||||
|
[(cg.uint8, CONF_PROGRAM), (cg.uint8, CONF_CHANNEL)],
|
||||||
|
),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_PITCH_BEND,
|
||||||
|
"add_on_pitch_bend_callback",
|
||||||
|
[(cg.int16, CONF_VALUE), (cg.uint8, CONF_CHANNEL)],
|
||||||
|
),
|
||||||
|
automation.CallbackAutomation(CONF_ON_SYSEX, "add_on_sysex_callback", _BYTES_ARGS),
|
||||||
|
automation.CallbackAutomation(
|
||||||
|
CONF_ON_MESSAGE, "add_on_message_callback", _BYTES_ARGS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = (
|
||||||
|
cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.declare_id(BLEMidi),
|
||||||
|
cv.GenerateID(CONF_SYSEX_BUFFER_ID): cv.declare_id(cg.uint8),
|
||||||
|
cv.Optional(CONF_MAX_SYSEX_SIZE, default=256): cv.int_range(
|
||||||
|
min=8, max=2048
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_PAIR, default=False): cv.boolean,
|
||||||
|
**{
|
||||||
|
cv.Optional(entry.conf_key): (
|
||||||
|
automation.validate_synchronous_automation(
|
||||||
|
f"ble_midi {entry.conf_key}", "data"
|
||||||
|
)
|
||||||
|
if entry.conf_key in _BYTES_TRIGGERS
|
||||||
|
else automation.validate_automation({})
|
||||||
|
)
|
||||||
|
for entry in _CALLBACK_AUTOMATIONS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.extend(cv.COMPONENT_SCHEMA)
|
||||||
|
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config: ConfigType) -> None:
|
||||||
|
size = config[CONF_MAX_SYSEX_SIZE]
|
||||||
|
# SysEx messages are reassembled in a static array, so nothing is allocated at run time
|
||||||
|
buf = config[CONF_SYSEX_BUFFER_ID].id
|
||||||
|
cg.add_global(cg.RawStatement(f"static uint8_t {buf}[{size}];"))
|
||||||
|
var = await ble_client.new_ble_node_component(
|
||||||
|
config, cg.RawExpression(buf), size, config[CONF_PAIR]
|
||||||
|
)
|
||||||
|
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_schema(keys: dict) -> cv.Schema:
|
||||||
|
return cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||||
|
**keys,
|
||||||
|
cv.Optional(CONF_CHANNEL, default=0): cv.templatable(midi_channel),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_ACTION_SCHEMA = _action_schema(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_NOTE): cv.templatable(midi_data_byte),
|
||||||
|
cv.Optional(CONF_VELOCITY, default=64): cv.templatable(midi_data_byte),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wider than the wire values so the C++ range check sees a lambda's result before it is narrowed
|
||||||
|
_CHANNEL = (CONF_CHANNEL, cg.int32)
|
||||||
|
_NOTE_CALL_ARGS = ((CONF_NOTE, cg.int32), (CONF_VELOCITY, cg.int32), _CHANNEL)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_note_on",
|
||||||
|
_NOTE_ACTION_SCHEMA,
|
||||||
|
automation.ApplyCall("send_note_on({}, {}, {})", _NOTE_CALL_ARGS),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_note_off",
|
||||||
|
_NOTE_ACTION_SCHEMA,
|
||||||
|
automation.ApplyCall("send_note_off({}, {}, {})", _NOTE_CALL_ARGS),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_control_change",
|
||||||
|
_action_schema(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_CONTROL_NUMBER): cv.templatable(midi_data_byte),
|
||||||
|
cv.Required(CONF_VALUE): cv.templatable(midi_data_byte),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
automation.ApplyCall(
|
||||||
|
"send_control_change({}, {}, {})",
|
||||||
|
((CONF_CONTROL_NUMBER, cg.int32), (CONF_VALUE, cg.int32), _CHANNEL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_program_change",
|
||||||
|
_action_schema({cv.Required(CONF_PROGRAM): cv.templatable(midi_data_byte)}),
|
||||||
|
automation.ApplyCall(
|
||||||
|
"send_program_change({}, {})", ((CONF_PROGRAM, cg.int32), _CHANNEL)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_pitch_bend",
|
||||||
|
_action_schema(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_VALUE): cv.templatable(cv.int_range(min=-8192, max=8191)),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
automation.ApplyCall("send_pitch_bend({}, {})", ((CONF_VALUE, cg.int32), _CHANNEL)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_action_schema(key: str) -> cv.Schema:
|
||||||
|
return cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||||
|
cv.Required(key): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_sysex",
|
||||||
|
_bytes_action_schema(CONF_PAYLOAD),
|
||||||
|
automation.ApplyBytes(CONF_PAYLOAD, "send_sysex({})"),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_action(
|
||||||
|
"ble_midi.send_raw",
|
||||||
|
_bytes_action_schema(CONF_DATA),
|
||||||
|
automation.ApplyBytes(CONF_DATA, "send_raw({})"),
|
||||||
|
)
|
||||||
|
|
||||||
|
automation.register_apply_condition(
|
||||||
|
"ble_midi.connected", cv.Schema({cv.GenerateID(): cv.use_id(BLEMidi)}), "is_ready()"
|
||||||
|
)
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#include "ble_midi.h"
|
||||||
|
|
||||||
|
#ifdef USE_ESP32
|
||||||
|
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace esphome::ble_midi {
|
||||||
|
|
||||||
|
ESPHOME_LOG_TAG(TAG, "ble_midi");
|
||||||
|
|
||||||
|
// Standard BLE-MIDI service and characteristic, as defined by the BLE-MIDI
|
||||||
|
// specification.
|
||||||
|
static constexpr const char *SERVICE_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700";
|
||||||
|
static constexpr const char *CHARACTERISTIC_UUID = "7772E5DB-3868-4112-A1A9-F2669D106BF3";
|
||||||
|
|
||||||
|
// MIDI status bytes for the messages this component can transmit.
|
||||||
|
static constexpr uint8_t STATUS_NOTE_OFF = 0x80;
|
||||||
|
static constexpr uint8_t STATUS_NOTE_ON = 0x90;
|
||||||
|
static constexpr uint8_t STATUS_CONTROL_CHANGE = 0xB0;
|
||||||
|
static constexpr uint8_t STATUS_PROGRAM_CHANGE = 0xC0;
|
||||||
|
static constexpr uint8_t STATUS_PITCH_BEND = 0xE0;
|
||||||
|
|
||||||
|
// Number of received bytes included in verbose packet logs.
|
||||||
|
static constexpr size_t LOG_MAX_BYTES = 32;
|
||||||
|
|
||||||
|
void BLEMidi::dump_config() {
|
||||||
|
ESP_LOGCONFIG(TAG,
|
||||||
|
"BLE MIDI:\n"
|
||||||
|
" MAC address: %s\n"
|
||||||
|
" Maximum SysEx size: %u\n"
|
||||||
|
" Pair on connect: %s",
|
||||||
|
this->parent()->address_str(), this->parser_.sysex_capacity(), YESNO(this->pair_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {
|
||||||
|
switch (event) {
|
||||||
|
case ESP_GATTC_OPEN_EVT: {
|
||||||
|
if (param->open.status != ESP_GATT_OK)
|
||||||
|
break;
|
||||||
|
// A warning left by an earlier session no longer applies
|
||||||
|
this->status_clear_warning();
|
||||||
|
this->parser_.reset();
|
||||||
|
if (this->pair_) {
|
||||||
|
esp_err_t err = this->parent()->pair();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Pairing failed, error=%d", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_CFG_MTU_EVT: {
|
||||||
|
if (param->cfg_mtu.status == ESP_GATT_OK)
|
||||||
|
this->mtu_ = param->cfg_mtu.mtu;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_DISCONNECT_EVT:
|
||||||
|
case ESP_GATTC_CLOSE_EVT: {
|
||||||
|
this->parser_.reset();
|
||||||
|
this->characteristic_handle_ = 0;
|
||||||
|
this->cccd_handle_ = 0;
|
||||||
|
this->mtu_ = 23;
|
||||||
|
// Both events can arrive for the same disconnect, so the automation runs
|
||||||
|
// only for the one that follows an established MIDI connection.
|
||||||
|
const bool was_ready = this->notify_registered_;
|
||||||
|
this->notify_registered_ = false;
|
||||||
|
this->node_state = espbt::ClientState::IDLE;
|
||||||
|
if (was_ready)
|
||||||
|
this->disconnect_callback_.call();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||||
|
if (param->search_cmpl.status != ESP_GATT_OK) {
|
||||||
|
ESP_LOGW(TAG, "Service discovery failed, status=%d", param->search_cmpl.status);
|
||||||
|
this->fail_session_();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
auto *characteristic = this->parent()->get_characteristic(espbt::ESPBTUUID::from_raw(SERVICE_UUID),
|
||||||
|
espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID));
|
||||||
|
// Reconnecting would not help a device without them, so the link is left as it is
|
||||||
|
if (characteristic == nullptr) {
|
||||||
|
this->status_set_warning(LOG_STR("No BLE MIDI characteristic found on this device"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
auto *cccd = characteristic->get_descriptor(ESP_GATT_UUID_CHAR_CLIENT_CONFIG);
|
||||||
|
if (cccd == nullptr) {
|
||||||
|
this->status_set_warning(LOG_STR("BLE MIDI characteristic has no notification descriptor"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this->characteristic_handle_ = characteristic->handle;
|
||||||
|
this->cccd_handle_ = cccd->handle;
|
||||||
|
this->write_without_response_ = (characteristic->properties & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) != 0;
|
||||||
|
esp_err_t err = this->parent()->register_for_notify(characteristic->handle);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "register_for_notify failed, error=%d", err);
|
||||||
|
this->fail_session_();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||||
|
if (param->reg_for_notify.handle != this->characteristic_handle_)
|
||||||
|
break;
|
||||||
|
if (param->reg_for_notify.status != ESP_GATT_OK) {
|
||||||
|
ESP_LOGW(TAG, "Registering for notifications failed, status=%d", param->reg_for_notify.status);
|
||||||
|
this->fail_session_();
|
||||||
|
}
|
||||||
|
// On success the parent writes the descriptor; the device has accepted it once WRITE_DESCR_EVT reports OK
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_WRITE_DESCR_EVT: {
|
||||||
|
if (this->cccd_handle_ == 0 || param->write.handle != this->cccd_handle_ || this->notify_registered_)
|
||||||
|
break;
|
||||||
|
if (param->write.status != ESP_GATT_OK) {
|
||||||
|
ESP_LOGW(TAG, "Device refused to enable notifications, status=%d", param->write.status);
|
||||||
|
this->fail_session_();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this->notify_registered_ = true;
|
||||||
|
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||||
|
ESP_LOGI(TAG, "MIDI notifications enabled");
|
||||||
|
// The device can only be sent to once notifications are enabled, so this
|
||||||
|
// is the point where on_connect is useful.
|
||||||
|
this->connect_callback_.call();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_WRITE_CHAR_EVT: {
|
||||||
|
// A write with response reports its outcome here; one without response reports nothing
|
||||||
|
if (param->write.handle == this->characteristic_handle_ && param->write.status != ESP_GATT_OK) {
|
||||||
|
ESP_LOGW(TAG, "Write rejected, status=%d", param->write.status);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ESP_GATTC_NOTIFY_EVT: {
|
||||||
|
if (param->notify.handle != this->characteristic_handle_ || param->notify.value_len == 0)
|
||||||
|
break;
|
||||||
|
// format_hex_to() truncates to the buffer size, so long packets are logged
|
||||||
|
// only in part.
|
||||||
|
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||||
|
ESP_LOGV(TAG, "Received %u bytes: %s", param->notify.value_len,
|
||||||
|
format_hex_to(hex, param->notify.value, param->notify.value_len));
|
||||||
|
this->parser_.feed(param->notify.value, param->notify.value_len, BLEMidi::dispatch_message, this);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::fail_session_() {
|
||||||
|
this->status_set_warning();
|
||||||
|
// ble_client reconnects, which runs the setup again
|
||||||
|
this->parent()->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::handle_message_(const MidiMessage &message) {
|
||||||
|
ESP_LOGV(
|
||||||
|
TAG, "Decoded %s",
|
||||||
|
LOG_STR_ARG(MessageTypeNames::get_log_str(static_cast<uint8_t>(message.type), MessageTypeNames::LAST_INDEX)));
|
||||||
|
// Only decoded messages reach on_message
|
||||||
|
if (message.type == MessageType::SYSEX_INCOMPLETE) {
|
||||||
|
ESP_LOGW(TAG, "SysEx of %u bytes dropped: cut short, or longer than max_sysex_size (%u)", message.length,
|
||||||
|
this->parser_.sysex_capacity());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type == MessageType::INCOMPLETE) {
|
||||||
|
ESP_LOGW(TAG, "MIDI message 0x%02X dropped: cut short by another status byte", message.status());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type == MessageType::UNKNOWN) {
|
||||||
|
ESP_LOGW(TAG, "Undecodable MIDI byte 0x%02X", message.status());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->message_callback_.call(ByteSpan(message.data, message.length));
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case MessageType::NOTE_ON:
|
||||||
|
this->note_on_callback_.call(message.data1(), message.data2(), message.channel());
|
||||||
|
break;
|
||||||
|
case MessageType::NOTE_OFF:
|
||||||
|
this->note_off_callback_.call(message.data1(), message.data2(), message.channel());
|
||||||
|
break;
|
||||||
|
case MessageType::CONTROL_CHANGE:
|
||||||
|
this->control_change_callback_.call(message.data1(), message.data2(), message.channel());
|
||||||
|
break;
|
||||||
|
case MessageType::PROGRAM_CHANGE:
|
||||||
|
this->program_change_callback_.call(message.data1(), message.channel());
|
||||||
|
break;
|
||||||
|
case MessageType::PITCH_BEND:
|
||||||
|
this->pitch_bend_callback_.call(message.pitch_bend(), message.channel());
|
||||||
|
break;
|
||||||
|
case MessageType::SYSEX:
|
||||||
|
// The payload without the 0xF0/0xF7 wrappers; a complete SysEx always has both
|
||||||
|
this->sysex_callback_.call(ByteSpan(message.data + 1, message.length - 2));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Polyphonic and channel aftertouch, system common and real-time messages
|
||||||
|
// are only reported through on_message.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BLEMidi::write_packet_(uint8_t *packet, size_t midi_length) {
|
||||||
|
if (!this->is_ready()) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: not connected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Every BLE-MIDI packet has to fit one ATT packet
|
||||||
|
const size_t packet_length = midi_length + 2;
|
||||||
|
const size_t limit = std::min<size_t>(MAX_TX_MIDI_BYTES + 2, this->mtu_ - 3);
|
||||||
|
if (packet_length > limit) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: message of %u bytes exceeds the %u byte limit", static_cast<unsigned>(midi_length),
|
||||||
|
static_cast<unsigned>(limit - 2));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
packet[0] = PACKET_HEADER;
|
||||||
|
packet[1] = PACKET_TIMESTAMP;
|
||||||
|
esp_err_t err = esp_ble_gattc_write_char(
|
||||||
|
this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->characteristic_handle_, packet_length,
|
||||||
|
packet, this->write_without_response_ ? ESP_GATT_WRITE_TYPE_NO_RSP : ESP_GATT_WRITE_TYPE_RSP,
|
||||||
|
ESP_GATT_AUTH_REQ_NONE);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Write failed, error=%d", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||||
|
ESP_LOGV(TAG, "Sent %u bytes: %s", static_cast<unsigned>(packet_length), format_hex_to(hex, packet, packet_length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BLEMidi::write_midi_(const uint8_t *midi_bytes, size_t length) {
|
||||||
|
if (length == 0 || length > MAX_TX_MIDI_BYTES) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: message of %u bytes, the limit is %u", static_cast<unsigned>(length),
|
||||||
|
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||||
|
std::memcpy(packet + 2, midi_bytes, length);
|
||||||
|
return this->write_packet_(packet, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length) {
|
||||||
|
// Validation covers constants only; a lambda can return any value, and masking it would send a different message.
|
||||||
|
// The values arrive unnarrowed so a negative or wrapped one is caught here (the unsigned compare covers negatives).
|
||||||
|
if (static_cast<uint32_t>(channel) > 0x0F || static_cast<uint32_t>(data1) > 0x7F ||
|
||||||
|
static_cast<uint32_t>(data2) > 0x7F) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: channel %" PRId32 " or value %" PRId32 "/%" PRId32 " out of range", channel, data1,
|
||||||
|
data2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t bytes[3] = {static_cast<uint8_t>(status | channel), static_cast<uint8_t>(data1),
|
||||||
|
static_cast<uint8_t>(data2)};
|
||||||
|
this->write_midi_(bytes, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_note_on(int32_t note, int32_t velocity, int32_t channel) {
|
||||||
|
this->send_channel_(STATUS_NOTE_ON, channel, note, velocity, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_note_off(int32_t note, int32_t velocity, int32_t channel) {
|
||||||
|
this->send_channel_(STATUS_NOTE_OFF, channel, note, velocity, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_control_change(int32_t control_number, int32_t value, int32_t channel) {
|
||||||
|
this->send_channel_(STATUS_CONTROL_CHANGE, channel, control_number, value, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_program_change(int32_t program, int32_t channel) {
|
||||||
|
this->send_channel_(STATUS_PROGRAM_CHANGE, channel, program, 0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_pitch_bend(int32_t value, int32_t channel) {
|
||||||
|
if (value < -8192 || value > 8191) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: pitch bend %" PRId32 " out of range", value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint16_t unsigned_value = static_cast<uint16_t>(value + 8192);
|
||||||
|
this->send_channel_(STATUS_PITCH_BEND, channel, unsigned_value & 0x7F, unsigned_value >> 7, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_sysex(const uint8_t *payload, size_t length) {
|
||||||
|
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||||
|
const size_t framed = frame_sysex(payload, length, packet + 2, MAX_TX_MIDI_BYTES);
|
||||||
|
if (framed == 0) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: SysEx of %u bytes is empty or exceeds the %u byte limit", static_cast<unsigned>(length),
|
||||||
|
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->write_packet_(packet, framed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BLEMidi::send_raw(const uint8_t *bytes, size_t length) {
|
||||||
|
// The packet carries one timestamp, in front of the first byte, so the payload has to be a single complete
|
||||||
|
// message with its status byte first; a SysEx goes through send_sysex
|
||||||
|
bool one_message = length > 0 && (bytes[0] & 0x80) != 0 && bytes[0] != STATUS_SYSEX_START &&
|
||||||
|
bytes[0] != STATUS_SYSEX_END && length == 1u + MidiParser::expected_data_bytes(bytes[0]);
|
||||||
|
for (size_t i = 1; one_message && i < length; i++) {
|
||||||
|
one_message = (bytes[i] & 0x80) == 0;
|
||||||
|
}
|
||||||
|
if (!one_message) {
|
||||||
|
ESP_LOGW(TAG, "Cannot send: raw payload must be one message, a status byte followed by data bytes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->write_midi_(bytes, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::ble_midi
|
||||||
|
|
||||||
|
#endif // USE_ESP32
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/core/defines.h"
|
||||||
|
#include "esphome/core/helpers.h"
|
||||||
|
|
||||||
|
#ifdef USE_ESP32
|
||||||
|
|
||||||
|
#include "esphome/components/ble_client/ble_client.h"
|
||||||
|
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||||
|
|
||||||
|
#include "midi_parser.h"
|
||||||
|
|
||||||
|
#include <esp_gattc_api.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
|
||||||
|
namespace esphome::ble_midi {
|
||||||
|
|
||||||
|
namespace espbt = esphome::esp32_ble_tracker;
|
||||||
|
|
||||||
|
/// The bytes of a received message, valid for the duration of the automation.
|
||||||
|
using ByteSpan = std::span<const uint8_t>;
|
||||||
|
|
||||||
|
/// Longest MIDI message that can be transmitted in a single BLE-MIDI packet, header and timestamp excluded.
|
||||||
|
static constexpr size_t MAX_TX_MIDI_BYTES = 128;
|
||||||
|
|
||||||
|
/// Client for devices implementing the standard BLE-MIDI service, e.g. wireless
|
||||||
|
/// MIDI keyboards and pad controllers. Incoming messages are exposed as
|
||||||
|
/// automation triggers; outgoing messages are sent with the ble_midi.send_*
|
||||||
|
/// actions.
|
||||||
|
///
|
||||||
|
/// Specification: "Specification for MIDI over Bluetooth Low Energy
|
||||||
|
/// (BLE-MIDI)", MIDI Association, document M1-2017-11-01.
|
||||||
|
class BLEMidi final : public Component, public ble_client::BLEClientNode {
|
||||||
|
public:
|
||||||
|
/// sysex_buffer holds sysex_capacity bytes; longer SysEx messages are truncated.
|
||||||
|
BLEMidi(uint8_t *sysex_buffer, uint16_t sysex_capacity, bool pair)
|
||||||
|
: parser_(sysex_buffer, sysex_capacity), pair_(pair) {}
|
||||||
|
|
||||||
|
void dump_config() override;
|
||||||
|
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||||
|
|
||||||
|
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||||
|
esp_ble_gattc_cb_param_t *param) override;
|
||||||
|
|
||||||
|
/// True once the MIDI characteristic has been discovered and notifications
|
||||||
|
/// are enabled.
|
||||||
|
bool is_ready() const { return this->notify_registered_; }
|
||||||
|
|
||||||
|
// Channel is 0-15, note/velocity/controller/value/program are 0-127; anything else is refused with a warning.
|
||||||
|
void send_note_on(int32_t note, int32_t velocity, int32_t channel);
|
||||||
|
void send_note_off(int32_t note, int32_t velocity, int32_t channel);
|
||||||
|
void send_control_change(int32_t control_number, int32_t value, int32_t channel);
|
||||||
|
void send_program_change(int32_t program, int32_t channel);
|
||||||
|
/// Pitch bend value is -8192 to 8191, centered on zero.
|
||||||
|
void send_pitch_bend(int32_t value, int32_t channel);
|
||||||
|
/// Send a System Exclusive message. The 0xF0/0xF7 wrappers are added if the
|
||||||
|
/// payload does not already carry them.
|
||||||
|
void send_sysex(const uint8_t *payload, size_t length);
|
||||||
|
/// Send raw MIDI bytes (status byte followed by its data bytes).
|
||||||
|
void send_raw(const uint8_t *bytes, size_t length);
|
||||||
|
|
||||||
|
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||||
|
this->connect_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||||
|
this->disconnect_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// (note, velocity, channel)
|
||||||
|
template<typename F> void add_on_note_on_callback(F &&callback) {
|
||||||
|
this->note_on_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// (note, velocity, channel); a Note On with velocity zero is reported here as well
|
||||||
|
template<typename F> void add_on_note_off_callback(F &&callback) {
|
||||||
|
this->note_off_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// (control_number, value, channel)
|
||||||
|
template<typename F> void add_on_control_change_callback(F &&callback) {
|
||||||
|
this->control_change_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// (program, channel)
|
||||||
|
template<typename F> void add_on_program_change_callback(F &&callback) {
|
||||||
|
this->program_change_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// (value, channel), value being -8192 to 8191
|
||||||
|
template<typename F> void add_on_pitch_bend_callback(F &&callback) {
|
||||||
|
this->pitch_bend_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// The SysEx payload without the 0xF0/0xF7 wrappers
|
||||||
|
template<typename F> void add_on_sysex_callback(F &&callback) {
|
||||||
|
this->sysex_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
/// The raw bytes of every decoded message
|
||||||
|
template<typename F> void add_on_message_callback(F &&callback) {
|
||||||
|
this->message_callback_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
static void dispatch_message(void *ctx, const MidiMessage &message) {
|
||||||
|
static_cast<BLEMidi *>(ctx)->handle_message_(message);
|
||||||
|
}
|
||||||
|
void handle_message_(const MidiMessage &message);
|
||||||
|
/// Wrap MIDI bytes in a BLE-MIDI packet and write them to the MIDI characteristic.
|
||||||
|
bool write_midi_(const uint8_t *midi_bytes, size_t length);
|
||||||
|
/// Write a packet whose MIDI bytes already sit at packet + 2; the header and timestamp are filled in.
|
||||||
|
bool write_packet_(uint8_t *packet, size_t midi_length);
|
||||||
|
/// Channel-voice message: the status byte takes the channel; out-of-range values are refused.
|
||||||
|
void send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length);
|
||||||
|
/// Set a warning and drop the link so that ble_client reconnects and the setup runs again.
|
||||||
|
void fail_session_();
|
||||||
|
|
||||||
|
MidiParser parser_;
|
||||||
|
LazyCallbackManager<void()> connect_callback_;
|
||||||
|
LazyCallbackManager<void()> disconnect_callback_;
|
||||||
|
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_on_callback_;
|
||||||
|
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_off_callback_;
|
||||||
|
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> control_change_callback_;
|
||||||
|
LazyCallbackManager<void(uint8_t, uint8_t)> program_change_callback_;
|
||||||
|
LazyCallbackManager<void(int16_t, uint8_t)> pitch_bend_callback_;
|
||||||
|
LazyCallbackManager<void(ByteSpan)> sysex_callback_;
|
||||||
|
LazyCallbackManager<void(ByteSpan)> message_callback_;
|
||||||
|
uint16_t characteristic_handle_{0};
|
||||||
|
// Notification descriptor of the MIDI characteristic; the link is ready once the device accepts the write to it
|
||||||
|
uint16_t cccd_handle_{0};
|
||||||
|
// The ATT MTU the link negotiated; every packet has to fit it
|
||||||
|
uint16_t mtu_{23};
|
||||||
|
bool notify_registered_{false};
|
||||||
|
// Write without response has lower latency and is preferred when the
|
||||||
|
// characteristic supports it.
|
||||||
|
bool write_without_response_{false};
|
||||||
|
const bool pair_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::ble_midi
|
||||||
|
|
||||||
|
#endif // USE_ESP32
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
#include "midi_parser.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace esphome::ble_midi {
|
||||||
|
|
||||||
|
static MessageType type_for_status(uint8_t status) {
|
||||||
|
if (status >= 0xF8)
|
||||||
|
return MessageType::SYSTEM_REAL_TIME;
|
||||||
|
if (status >= 0xF1 && status <= 0xF6)
|
||||||
|
return MessageType::SYSTEM_COMMON;
|
||||||
|
switch (status & 0xF0) {
|
||||||
|
case 0x80:
|
||||||
|
return MessageType::NOTE_OFF;
|
||||||
|
case 0x90:
|
||||||
|
return MessageType::NOTE_ON;
|
||||||
|
case 0xA0:
|
||||||
|
return MessageType::POLY_AFTERTOUCH;
|
||||||
|
case 0xB0:
|
||||||
|
return MessageType::CONTROL_CHANGE;
|
||||||
|
case 0xC0:
|
||||||
|
return MessageType::PROGRAM_CHANGE;
|
||||||
|
case 0xD0:
|
||||||
|
return MessageType::CHANNEL_AFTERTOUCH;
|
||||||
|
case 0xE0:
|
||||||
|
return MessageType::PITCH_BEND;
|
||||||
|
default:
|
||||||
|
return MessageType::UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data bytes that follow a channel-voice or system-common status byte
|
||||||
|
uint8_t MidiParser::expected_data_bytes(uint8_t status) {
|
||||||
|
if (status >= 0xF0) {
|
||||||
|
switch (status) {
|
||||||
|
case 0xF1: // MIDI Time Code Quarter Frame
|
||||||
|
case 0xF3: // Song Select
|
||||||
|
return 1;
|
||||||
|
case 0xF2: // Song Position Pointer
|
||||||
|
return 2;
|
||||||
|
default: // 0xF4, 0xF5 undefined, 0xF6 Tune Request
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (status & 0xF0) {
|
||||||
|
case 0xC0: // Program Change
|
||||||
|
case 0xD0: // Channel Aftertouch
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiParser::reset() {
|
||||||
|
this->running_status_ = 0;
|
||||||
|
this->sysex_active_ = false;
|
||||||
|
this->sysex_length_ = 0;
|
||||||
|
this->current_length_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiParser::sysex_push_(uint8_t byte) {
|
||||||
|
if (this->sysex_length_ < this->sysex_capacity_) {
|
||||||
|
this->sysex_buffer_[this->sysex_length_++] = byte;
|
||||||
|
} else {
|
||||||
|
this->sysex_overflow_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends the SysEx being assembled; it is complete when 0xF7 closed it and every byte fit the buffer
|
||||||
|
void MidiParser::end_sysex_(bool complete, MidiMessageHandler handler, void *ctx) {
|
||||||
|
this->sysex_active_ = false;
|
||||||
|
this->running_status_ = 0;
|
||||||
|
this->emit_(complete && !this->sysex_overflow_ ? MessageType::SYSEX : MessageType::SYSEX_INCOMPLETE,
|
||||||
|
this->sysex_buffer_, this->sysex_length_, handler, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiParser::emit_current_(MidiMessageHandler handler, void *ctx) {
|
||||||
|
const uint8_t status = this->current_message_[0];
|
||||||
|
MessageType type = type_for_status(status);
|
||||||
|
// A Note On with velocity zero is canonically a Note Off.
|
||||||
|
if (type == MessageType::NOTE_ON && this->current_message_[2] == 0)
|
||||||
|
type = MessageType::NOTE_OFF;
|
||||||
|
// System Common clears running status; channel-voice messages set it.
|
||||||
|
this->running_status_ = status >= 0xF0 ? 0 : status;
|
||||||
|
const uint16_t length = this->current_length_;
|
||||||
|
this->current_length_ = 0;
|
||||||
|
this->emit_(type, this->current_message_, length, handler, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes a data byte for the message being assembled, or starts one from the running status
|
||||||
|
void MidiParser::take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx) {
|
||||||
|
if (this->current_length_ == 0) {
|
||||||
|
if (this->running_status_ == 0) {
|
||||||
|
// Data byte without any status context.
|
||||||
|
this->emit_(MessageType::UNKNOWN, byte, 1, handler, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->current_message_[0] = this->running_status_;
|
||||||
|
this->current_length_ = 1;
|
||||||
|
this->current_expected_ = expected_data_bytes(this->running_status_);
|
||||||
|
}
|
||||||
|
this->current_message_[this->current_length_++] = *byte;
|
||||||
|
if (this->current_length_ > this->current_expected_)
|
||||||
|
this->emit_current_(handler, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiParser::feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx) {
|
||||||
|
// A BLE-MIDI packet is at least a header byte plus one timestamp or SysEx
|
||||||
|
// continuation byte, and the header byte always has bit 7 set. Anything else
|
||||||
|
// is malformed.
|
||||||
|
if (length < 2 || (data[0] & 0x80) == 0) {
|
||||||
|
for (size_t i = 0; i < length; i++)
|
||||||
|
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timestamp byte precedes every status byte, even a real-time byte inside another message, so a byte with
|
||||||
|
// bit 7 set is a status byte only right after a timestamp and a timestamp everywhere else
|
||||||
|
bool expect_timestamp = true;
|
||||||
|
for (size_t i = 1; i < length; i++) {
|
||||||
|
const uint8_t byte = data[i];
|
||||||
|
if ((byte & 0x80) == 0) {
|
||||||
|
// A data byte continues the SysEx, the message being assembled, or the running status
|
||||||
|
if (this->sysex_active_) {
|
||||||
|
this->sysex_push_(byte);
|
||||||
|
} else {
|
||||||
|
this->take_data_(data + i, handler, ctx);
|
||||||
|
}
|
||||||
|
expect_timestamp = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expect_timestamp) {
|
||||||
|
expect_timestamp = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect_timestamp = true;
|
||||||
|
if (byte >= 0xF8) {
|
||||||
|
// Real-time messages are single bytes that interrupt anything, a SysEx included, without affecting it
|
||||||
|
this->emit_(MessageType::SYSTEM_REAL_TIME, data + i, 1, handler, ctx);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Any other status byte cuts a message still being assembled short
|
||||||
|
if (this->current_length_ > 0) {
|
||||||
|
const uint16_t length = this->current_length_;
|
||||||
|
this->current_length_ = 0;
|
||||||
|
this->emit_(MessageType::INCOMPLETE, this->current_message_, length, handler, ctx);
|
||||||
|
}
|
||||||
|
if (byte == STATUS_SYSEX_END) {
|
||||||
|
if (this->sysex_active_) {
|
||||||
|
this->sysex_push_(STATUS_SYSEX_END);
|
||||||
|
this->end_sysex_(true, handler, ctx);
|
||||||
|
} else {
|
||||||
|
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Any status byte but 0xF7 ends a SysEx still open
|
||||||
|
if (this->sysex_active_)
|
||||||
|
this->end_sysex_(false, handler, ctx);
|
||||||
|
if (byte == STATUS_SYSEX_START) {
|
||||||
|
this->sysex_active_ = true;
|
||||||
|
this->sysex_length_ = 0;
|
||||||
|
this->sysex_overflow_ = false;
|
||||||
|
this->sysex_push_(STATUS_SYSEX_START);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this->current_message_[0] = byte;
|
||||||
|
this->current_length_ = 1;
|
||||||
|
this->current_expected_ = expected_data_bytes(byte);
|
||||||
|
// A status byte without data bytes, e.g. Tune Request, is a message on its own
|
||||||
|
if (this->current_expected_ == 0)
|
||||||
|
this->emit_current_(handler, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size) {
|
||||||
|
if (length == 0)
|
||||||
|
return 0;
|
||||||
|
const size_t skip_start = payload[0] == STATUS_SYSEX_START ? 1 : 0;
|
||||||
|
const size_t skip_end = payload[length - 1] == STATUS_SYSEX_END ? 1 : 0;
|
||||||
|
const size_t body = length - skip_start - skip_end;
|
||||||
|
// 0xF0, the body, then the timestamp before 0xF7
|
||||||
|
const size_t framed = body + 3;
|
||||||
|
if (framed > out_size)
|
||||||
|
return 0;
|
||||||
|
// A body byte with bit 7 set would be read as a timestamp or status byte
|
||||||
|
for (size_t i = 0; i < body; i++) {
|
||||||
|
if (payload[skip_start + i] & 0x80)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
out[0] = STATUS_SYSEX_START;
|
||||||
|
std::memcpy(out + 1, payload + skip_start, body);
|
||||||
|
out[body + 1] = PACKET_TIMESTAMP;
|
||||||
|
out[body + 2] = STATUS_SYSEX_END;
|
||||||
|
return framed;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::ble_midi
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/progmem.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace esphome::ble_midi {
|
||||||
|
|
||||||
|
// Decoded MIDI message types. Status bytes have the high bit set; the low nibble of a channel-voice status byte
|
||||||
|
// (0x80-0xEF) is the channel, system messages (0xF0-0xFF) have none.
|
||||||
|
enum class MessageType : uint8_t {
|
||||||
|
NOTE_OFF, // 0x80
|
||||||
|
NOTE_ON, // 0x90
|
||||||
|
POLY_AFTERTOUCH, // 0xA0
|
||||||
|
CONTROL_CHANGE, // 0xB0
|
||||||
|
PROGRAM_CHANGE, // 0xC0
|
||||||
|
CHANNEL_AFTERTOUCH, // 0xD0
|
||||||
|
PITCH_BEND, // 0xE0
|
||||||
|
SYSEX, // 0xF0 ... 0xF7, possibly spread over several BLE packets
|
||||||
|
SYSEX_INCOMPLETE, // a SysEx cut short by another status byte or longer than the buffer
|
||||||
|
SYSTEM_COMMON, // 0xF1-0xF6, e.g. quarter frame, song position
|
||||||
|
SYSTEM_REAL_TIME, // 0xF8-0xFF, e.g. clock, start, stop
|
||||||
|
INCOMPLETE, // a channel or system common message cut short by another status byte
|
||||||
|
UNKNOWN, // malformed/undecodable byte
|
||||||
|
};
|
||||||
|
|
||||||
|
PROGMEM_STRING_TABLE(MessageTypeNames, "NOTE_OFF", "NOTE_ON", "POLY_AFTERTOUCH", "CONTROL_CHANGE", "PROGRAM_CHANGE",
|
||||||
|
"CHANNEL_AFTERTOUCH", "PITCH_BEND", "SYSEX", "SYSEX_INCOMPLETE", "SYSTEM_COMMON",
|
||||||
|
"SYSTEM_REAL_TIME", "INCOMPLETE", "UNKNOWN");
|
||||||
|
|
||||||
|
/// A decoded MIDI message. `data` points into the parser's storage or the packet being fed, so it is only valid
|
||||||
|
/// for the duration of the handler.
|
||||||
|
struct MidiMessage {
|
||||||
|
// Status byte followed by its data bytes; a SysEx includes the 0xF0/0xF7 wrappers
|
||||||
|
const uint8_t *data{nullptr};
|
||||||
|
uint16_t length{0};
|
||||||
|
MessageType type{MessageType::UNKNOWN};
|
||||||
|
|
||||||
|
uint8_t status() const { return this->data[0]; }
|
||||||
|
uint8_t channel() const { return this->data[0] & 0x0F; }
|
||||||
|
// The data bytes of a complete channel-voice message: note and velocity, controller and value, or program
|
||||||
|
uint8_t data1() const { return this->data[1]; }
|
||||||
|
uint8_t data2() const { return this->data[2]; }
|
||||||
|
// PITCH_BEND: 14-bit value re-centered on zero (-8192..8191).
|
||||||
|
int16_t pitch_bend() const {
|
||||||
|
return static_cast<int16_t>((static_cast<uint16_t>(this->data[2]) << 7) | this->data[1]) - 8192;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Called once per decoded message with the context passed to feed().
|
||||||
|
using MidiMessageHandler = void (*)(void *ctx, const MidiMessage &message);
|
||||||
|
|
||||||
|
/// Decoder for the BLE-MIDI packet format (Apple/MMA "Bluetooth Low Energy
|
||||||
|
/// MIDI" specification, section 3).
|
||||||
|
///
|
||||||
|
/// Packet layout:
|
||||||
|
/// [header] [timestamp] [MIDI 0] ... ([timestamp] [MIDI n])*
|
||||||
|
///
|
||||||
|
/// - header : 0b10hhhhhh, bit 7 set, bits 5-0 hold the high bits of the
|
||||||
|
/// timestamp
|
||||||
|
/// - timestamp : 0b1lllllll, bit 7 set, bits 6-0 hold the low bits of the
|
||||||
|
/// timestamp
|
||||||
|
///
|
||||||
|
/// A timestamp byte precedes every status byte, except where running status
|
||||||
|
/// applies. SysEx messages may span several BLE packets and may be interrupted
|
||||||
|
/// by real-time messages (0xF8-0xFF).
|
||||||
|
///
|
||||||
|
/// Timestamps are used for jitter reconstruction by MIDI hosts and are ignored
|
||||||
|
/// here.
|
||||||
|
///
|
||||||
|
/// This class holds no BLE or ESPHome state so that it can be exercised in
|
||||||
|
/// isolation.
|
||||||
|
class MidiParser final {
|
||||||
|
public:
|
||||||
|
/// `sysex_buffer` reassembles SysEx messages; one longer than `sysex_capacity` bytes is reported incomplete.
|
||||||
|
MidiParser(uint8_t *sysex_buffer, uint16_t sysex_capacity)
|
||||||
|
: sysex_buffer_(sysex_buffer), sysex_capacity_(sysex_capacity) {}
|
||||||
|
|
||||||
|
uint16_t sysex_capacity() const { return this->sysex_capacity_; }
|
||||||
|
|
||||||
|
/// Drop all parser state. Call on disconnect so running status and partial
|
||||||
|
/// messages do not leak into the next session.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
/// Feed one BLE notification payload. Undecodable bytes are reported as MessageType::UNKNOWN so the
|
||||||
|
/// caller can log them.
|
||||||
|
void feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx);
|
||||||
|
|
||||||
|
/// Number of data bytes that follow a status byte other than SysEx.
|
||||||
|
static uint8_t expected_data_bytes(uint8_t status);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void emit_(MessageType type, const uint8_t *data, uint16_t length, MidiMessageHandler handler, void *ctx) {
|
||||||
|
handler(ctx, MidiMessage{data, length, type});
|
||||||
|
}
|
||||||
|
void emit_current_(MidiMessageHandler handler, void *ctx);
|
||||||
|
void take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx);
|
||||||
|
void end_sysex_(bool complete, MidiMessageHandler handler, void *ctx);
|
||||||
|
void sysex_push_(uint8_t byte);
|
||||||
|
|
||||||
|
// SysEx reassembly across packets.
|
||||||
|
uint8_t *const sysex_buffer_;
|
||||||
|
const uint16_t sysex_capacity_;
|
||||||
|
uint16_t sysex_length_{0};
|
||||||
|
bool sysex_active_{false};
|
||||||
|
bool sysex_overflow_{false};
|
||||||
|
|
||||||
|
// Last channel-voice status byte, sticky across bytes and packets until a new
|
||||||
|
// status byte or a system-common message clears it (MIDI running status).
|
||||||
|
uint8_t running_status_{0};
|
||||||
|
|
||||||
|
// Message being assembled: status byte plus up to two data bytes.
|
||||||
|
uint8_t current_message_[3]{};
|
||||||
|
uint8_t current_length_{0};
|
||||||
|
uint8_t current_expected_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
// A BLE-MIDI packet starts with a header byte and a timestamp byte, both with bit 7 set. The remaining
|
||||||
|
// timestamp bits serve jitter reconstruction only and are left at zero when transmitting.
|
||||||
|
static constexpr uint8_t PACKET_HEADER = 0x80;
|
||||||
|
static constexpr uint8_t PACKET_TIMESTAMP = 0x80;
|
||||||
|
static constexpr uint8_t STATUS_SYSEX_START = 0xF0;
|
||||||
|
static constexpr uint8_t STATUS_SYSEX_END = 0xF7;
|
||||||
|
|
||||||
|
/// Frame a SysEx payload for one BLE-MIDI packet: 0xF0 and 0xF7 are added when the payload lacks them, with
|
||||||
|
/// the timestamp byte the specification requires before 0xF7. Returns the bytes written to `out`, or 0 when
|
||||||
|
/// the payload is empty, holds a byte with bit 7 set, or does not fit `out_size` once framed.
|
||||||
|
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size);
|
||||||
|
|
||||||
|
} // namespace esphome::ble_midi
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from functools import partial
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation
|
||||||
@@ -46,16 +47,24 @@ from esphome.const import (
|
|||||||
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
|
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
|
||||||
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
|
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
|
||||||
CONF_TEMPERATURE_STEP,
|
CONF_TEMPERATURE_STEP,
|
||||||
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
CONF_VISUAL,
|
CONF_VISUAL,
|
||||||
CONF_WEB_SERVER,
|
CONF_WEB_SERVER,
|
||||||
|
UNIT_KELVIN,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||||
from esphome.core.entity_helpers import (
|
from esphome.core.entity_helpers import (
|
||||||
|
TemperatureUnitResolver,
|
||||||
|
convert_visual_temperatures,
|
||||||
|
declare_fixed_temperature_unit,
|
||||||
entity_duplicate_validator,
|
entity_duplicate_validator,
|
||||||
queue_entity_register,
|
queue_entity_register,
|
||||||
setup_entity,
|
setup_entity,
|
||||||
|
setup_entity_temperature_unit,
|
||||||
|
temperature_in_entity_unit,
|
||||||
)
|
)
|
||||||
from esphome.cpp_generator import MockObj, MockObjClass
|
from esphome.cpp_generator import MockObj, MockObjClass
|
||||||
|
import esphome.final_validate as fv
|
||||||
from esphome.types import ConfigType, SafeExpType
|
from esphome.types import ConfigType, SafeExpType
|
||||||
|
|
||||||
IS_PLATFORM_COMPONENT = True
|
IS_PLATFORM_COMPONENT = True
|
||||||
@@ -138,30 +147,33 @@ CONF_MIN_HUMIDITY = "min_humidity"
|
|||||||
CONF_MAX_HUMIDITY = "max_humidity"
|
CONF_MAX_HUMIDITY = "max_humidity"
|
||||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||||
|
|
||||||
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
|
||||||
|
|
||||||
|
|
||||||
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
|
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
|
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
|
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.5.0: external components imported it before temperature_with_unit replaced it
|
||||||
|
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
||||||
|
|
||||||
|
|
||||||
def visual_temperature_step(value: Any) -> ConfigType:
|
def visual_temperature_step(value: Any) -> ConfigType:
|
||||||
# Allow defining target/current temperature steps separately
|
# Allow defining target/current temperature steps separately
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
|
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
|
||||||
|
|
||||||
# Otherwise, use the single value for both properties
|
# Otherwise, use the single value for both properties
|
||||||
value = visual_temperature(value)
|
value = cv.temperature_with_unit(value)
|
||||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(
|
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
|
||||||
{
|
|
||||||
CONF_TARGET_TEMPERATURE: value,
|
|
||||||
CONF_CURRENT_TEMPERATURE: value,
|
_visual_temperatures_in_entity_unit = partial(
|
||||||
}
|
convert_visual_temperatures,
|
||||||
)
|
delta_keys=(CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE),
|
||||||
|
delta_section=CONF_TEMPERATURE_STEP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_CLIMATE_SCHEMA = (
|
_CLIMATE_SCHEMA = (
|
||||||
@@ -172,8 +184,8 @@ _CLIMATE_SCHEMA = (
|
|||||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
|
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
|
||||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
|
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
|
||||||
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
|
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
|
||||||
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
|
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
|
||||||
@@ -250,6 +262,24 @@ _CLIMATE_SCHEMA = (
|
|||||||
|
|
||||||
|
|
||||||
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
|
_CLIMATE_SCHEMA.add_extra(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(
|
def climate_schema(
|
||||||
@@ -257,10 +287,26 @@ def climate_schema(
|
|||||||
*,
|
*,
|
||||||
entity_category: str = cv.UNDEFINED,
|
entity_category: str = cv.UNDEFINED,
|
||||||
icon: str = cv.UNDEFINED,
|
icon: str = cv.UNDEFINED,
|
||||||
|
temperature_unit: bool = False,
|
||||||
|
fixed_temperature_unit: str | TemperatureUnitResolver | None = None,
|
||||||
) -> cv.Schema:
|
) -> 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 = {
|
schema = {
|
||||||
cv.GenerateID(): cv.declare_id(class_),
|
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 [
|
for key, default, validator in [
|
||||||
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
|
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
|
||||||
@@ -305,6 +351,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
|
|||||||
if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None:
|
if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None:
|
||||||
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
|
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
|
||||||
cg.add(var.set_visual_max_humidity_override(max_humidity))
|
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:
|
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||||
@@ -479,9 +526,9 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
|||||||
{
|
{
|
||||||
cv.Required(CONF_ID): cv.use_id(Climate),
|
cv.Required(CONF_ID): cv.use_id(Climate),
|
||||||
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
|
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||||
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
|
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
|
||||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||||
@@ -497,17 +544,23 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TARGET_TEMPERATURE_APPLY_FIELDS = tuple(
|
||||||
|
automation.ApplyField(key, setter, cg.float_, temperature_in_entity_unit)
|
||||||
|
for key, setter in (
|
||||||
|
(CONF_TARGET_TEMPERATURE, "set_target_temperature"),
|
||||||
|
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low"),
|
||||||
|
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TARGET_TEMPERATURE_KEYS = tuple(
|
||||||
|
field.conf_key for field in TARGET_TEMPERATURE_APPLY_FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
automation.register_apply_action(
|
automation.register_apply_action(
|
||||||
"climate.control",
|
"climate.control",
|
||||||
CLIMATE_CONTROL_ACTION_SCHEMA,
|
CLIMATE_CONTROL_ACTION_SCHEMA,
|
||||||
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
|
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
|
||||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
*TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||||
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_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||||
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
||||||
automation.ApplyField(
|
automation.ApplyField(
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class ClimateTraits {
|
|||||||
friend class Climate; // Climate wires its custom mode lists into traits
|
friend class Climate; // Climate wires its custom mode lists into traits
|
||||||
|
|
||||||
public:
|
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
|
// 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.
|
// template per traits() override, and .rodata is RAM on ESP8266.
|
||||||
ClimateTraits();
|
ClimateTraits();
|
||||||
@@ -193,8 +197,8 @@ class ClimateTraits {
|
|||||||
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
|
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
|
||||||
|
|
||||||
uint32_t feature_flags_{0};
|
uint32_t feature_flags_{0};
|
||||||
float visual_min_temperature_{10};
|
float visual_min_temperature_{DEFAULT_VISUAL_MIN_TEMPERATURE};
|
||||||
float visual_max_temperature_{30};
|
float visual_max_temperature_{DEFAULT_VISUAL_MAX_TEMPERATURE};
|
||||||
float visual_target_temperature_step_{0.1};
|
float visual_target_temperature_step_{0.1};
|
||||||
float visual_current_temperature_step_{0.1};
|
float visual_current_temperature_step_{0.1};
|
||||||
float visual_min_humidity_{30};
|
float visual_min_humidity_{30};
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ ClimateIR = climate_ir_ns.class_(
|
|||||||
|
|
||||||
def climate_ir_schema(
|
def climate_ir_schema(
|
||||||
class_: MockObjClass,
|
class_: MockObjClass,
|
||||||
|
*,
|
||||||
|
fixed_temperature_unit: str | None = None,
|
||||||
) -> cv.Schema:
|
) -> cv.Schema:
|
||||||
return (
|
return (
|
||||||
climate.climate_schema(class_)
|
climate.climate_schema(class_, fixed_temperature_unit=fixed_temperature_unit)
|
||||||
.extend(
|
.extend(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
|
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
|
||||||
@@ -52,8 +54,12 @@ def climate_ir_schema(
|
|||||||
|
|
||||||
def climate_ir_with_receiver_schema(
|
def climate_ir_with_receiver_schema(
|
||||||
class_: MockObjClass,
|
class_: MockObjClass,
|
||||||
|
*,
|
||||||
|
fixed_temperature_unit: str | None = None,
|
||||||
) -> cv.Schema:
|
) -> cv.Schema:
|
||||||
return climate_ir_schema(class_).extend(
|
return climate_ir_schema(
|
||||||
|
class_, fixed_temperature_unit=fixed_temperature_unit
|
||||||
|
).extend(
|
||||||
{
|
{
|
||||||
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
|
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
|
||||||
remote_base.RemoteReceiverBase
|
remote_base.RemoteReceiverBase
|
||||||
|
|||||||
@@ -13,19 +13,10 @@ from esphome.const import (
|
|||||||
from esphome.core.entity_helpers import inherit_property_from
|
from esphome.core.entity_helpers import inherit_property_from
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import copy_ns
|
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||||
|
{
|
||||||
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
|
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG_SCHEMA = (
|
|
||||||
sensor.sensor_schema(CopySensor)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.extend(cv.COMPONENT_SCHEMA)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||||
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
|||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||||
|
|
||||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#include "copy_sensor.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::copy {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "copy.sensor");
|
|
||||||
|
|
||||||
void CopySensor::setup() {
|
|
||||||
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
|
||||||
if (source_->has_state())
|
|
||||||
this->publish_state(source_->state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
|
|
||||||
|
|
||||||
} // namespace esphome::copy
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/sensor/sensor.h"
|
|
||||||
|
|
||||||
namespace esphome::copy {
|
|
||||||
|
|
||||||
class CopySensor final : public sensor::Sensor, public Component {
|
|
||||||
public:
|
|
||||||
void set_source(sensor::Sensor *source) { source_ = source; }
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
sensor::Sensor *source_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::copy
|
|
||||||
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
|
|||||||
from esphome.core.entity_helpers import inherit_property_from
|
from esphome.core.entity_helpers import inherit_property_from
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import copy_ns
|
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
|
||||||
|
{
|
||||||
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
|
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG_SCHEMA = (
|
|
||||||
text_sensor.text_sensor_schema(CopyTextSensor)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.extend(cv.COMPONENT_SCHEMA)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||||
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
|
|||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await text_sensor.new_text_sensor(config)
|
var = await text_sensor.new_text_sensor(config)
|
||||||
await cg.register_component(var, config)
|
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||||
|
|
||||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#include "copy_text_sensor.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::copy {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
|
|
||||||
|
|
||||||
void CopyTextSensor::setup() {
|
|
||||||
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
|
|
||||||
if (source_->has_state())
|
|
||||||
this->publish_state(source_->state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
|
|
||||||
|
|
||||||
} // namespace esphome::copy
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/text_sensor/text_sensor.h"
|
|
||||||
|
|
||||||
namespace esphome::copy {
|
|
||||||
|
|
||||||
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
|
|
||||||
public:
|
|
||||||
void set_source(text_sensor::TextSensor *source) { source_ = source; }
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
text_sensor::TextSensor *source_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::copy
|
|
||||||
@@ -5,6 +5,7 @@ from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
|
|||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@epicRE"]
|
CODEOWNERS = ["@epicRE"]
|
||||||
|
AUTO_LOAD = ["number"]
|
||||||
DEPENDENCIES = ["ble_client"]
|
DEPENDENCIES = ["ble_client"]
|
||||||
DOMAIN = "delta_wallbox"
|
DOMAIN = "delta_wallbox"
|
||||||
MULTI_CONF = True
|
MULTI_CONF = True
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -54,6 +55,8 @@ static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0
|
|||||||
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
||||||
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 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
|
// Indexed by DeltaState
|
||||||
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
|
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",
|
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
|
||||||
@@ -103,6 +106,13 @@ static bool verify_crc(const uint8_t *data, size_t len) {
|
|||||||
return calculated == received;
|
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
|
// 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) {
|
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
|
||||||
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
||||||
@@ -448,15 +458,16 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
|||||||
uint8_t byte_count = frame[2];
|
uint8_t byte_count = frame[2];
|
||||||
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
||||||
// Parsing 0x011B charging info (9 registers)
|
// Parsing 0x011B charging info (9 registers)
|
||||||
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
uint64_t power_raw = std::min<uint64_t>(read_be(&frame[3], 7), 999900);
|
||||||
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
|
uint64_t energy_raw = read_be(&frame[10], 4);
|
||||||
uint8_t minutes = frame[15];
|
uint8_t hours = std::min<uint8_t>(frame[14], 99);
|
||||||
uint8_t hours = frame[16];
|
uint8_t minutes = std::min<uint8_t>(frame[15], 59);
|
||||||
|
uint8_t seconds = std::min<uint8_t>(frame[16], 59);
|
||||||
uint8_t state_byte = frame[17];
|
uint8_t state_byte = frame[17];
|
||||||
|
|
||||||
float power_kw = power_raw / 1000.0f;
|
float power_kw = power_raw / 1000.0f;
|
||||||
float energy_kwh = energy_raw / 1000.0f;
|
float energy_kwh = energy_raw / 1000.0f;
|
||||||
float charging_minutes = hours * 60 + minutes;
|
float charging_minutes = hours * 60.0f + minutes + seconds / 60.0f;
|
||||||
|
|
||||||
if (this->power_sensor_ != nullptr)
|
if (this->power_sensor_ != nullptr)
|
||||||
this->power_sensor_->publish_state(power_kw);
|
this->power_sensor_->publish_state(power_kw);
|
||||||
@@ -471,11 +482,13 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
|||||||
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
||||||
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
||||||
// Parsing 0x0112 max output setting (2 registers)
|
// Parsing 0x0112 max output setting (2 registers)
|
||||||
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
// frame[4] is the configured max (set by DIP switch in device)
|
||||||
float amps = current_raw / 10.0f;
|
// frame[5] the active limit
|
||||||
if (this->max_current_sensor_ != nullptr) {
|
this->max_current_limit_ = frame[4];
|
||||||
this->max_current_sensor_->publish_state(amps);
|
if (this->max_current_sensor_ != nullptr)
|
||||||
}
|
this->max_current_sensor_->publish_state(frame[5]);
|
||||||
|
if (this->max_current_number_ != nullptr)
|
||||||
|
this->max_current_number_->publish_state(frame[5]);
|
||||||
|
|
||||||
// Poll the error code register every cycle
|
// Poll the error code register every cycle
|
||||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||||
@@ -488,8 +501,15 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
|||||||
this->error_code_text_sensor_->publish_state(hex_str);
|
this->error_code_text_sensor_->publish_state(hex_str);
|
||||||
}
|
}
|
||||||
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
||||||
// Confirmation frame of Start/Stop write actions
|
const uint16_t register_addr = (static_cast<uint16_t>(frame[2]) << 8) | frame[3];
|
||||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
if (register_addr == REG_MAX_CURRENT) {
|
||||||
|
ESP_LOGI(TAG, "Wallbox max current command acknowledged: %u A", frame[5]);
|
||||||
|
if (this->max_current_number_ != nullptr)
|
||||||
|
this->max_current_number_->publish_state(frame[5]);
|
||||||
|
} else {
|
||||||
|
// Confirmation frame of Start/Stop write actions
|
||||||
|
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,7 @@ void DeltaWallbox::reset_state_() {
|
|||||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||||
this->rx_len_ = 0;
|
this->rx_len_ = 0;
|
||||||
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
||||||
|
this->max_current_limit_ = 0; // refuse writes until the next 0x0112 read
|
||||||
this->pairing_attempts_ = 0;
|
this->pairing_attempts_ = 0;
|
||||||
this->pairing_in_progress_ = false;
|
this->pairing_in_progress_ = false;
|
||||||
this->status_set_warning();
|
this->status_set_warning();
|
||||||
@@ -533,6 +554,35 @@ void DeltaWallbox::set_charging_state(bool state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DeltaWallbox::set_max_current(float current) {
|
||||||
|
if (!this->is_connected()) {
|
||||||
|
ESP_LOGW(TAG, "Cannot set maximum current while wallbox is not fully connected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t upper = this->max_current_limit_;
|
||||||
|
if (upper == 0) {
|
||||||
|
ESP_LOGW(TAG, "Wallbox current limit not read yet; refusing to set %.0f A", current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int32_t rounded = std::lround(current);
|
||||||
|
if (rounded < 1) {
|
||||||
|
ESP_LOGW(TAG, "Rejecting maximum current %d A", static_cast<int>(rounded));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t requested = static_cast<uint8_t>(std::min<int32_t>(rounded, upper));
|
||||||
|
if (rounded > upper) {
|
||||||
|
ESP_LOGW(TAG, "Requested %d A exceeds wallbox limit %u A; using %u A", static_cast<int>(rounded), upper, upper);
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Setting wallbox maximum current to %u A", requested);
|
||||||
|
const std::array<uint8_t, 6> body = {0x01,
|
||||||
|
FC_WRITE_SINGLE_REGISTER,
|
||||||
|
static_cast<uint8_t>(REG_MAX_CURRENT >> 8),
|
||||||
|
static_cast<uint8_t>(REG_MAX_CURRENT & 0xFF),
|
||||||
|
0x00,
|
||||||
|
requested};
|
||||||
|
this->send_frame_(body.data(), body.size());
|
||||||
|
}
|
||||||
|
|
||||||
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
||||||
|
|
||||||
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include "esphome/core/component.h"
|
#include "esphome/core/component.h"
|
||||||
#include "esphome/components/ble_client/ble_client.h"
|
#include "esphome/components/ble_client/ble_client.h"
|
||||||
|
#include "esphome/components/number/number.h"
|
||||||
#include "esphome/components/sensor/sensor.h"
|
#include "esphome/components/sensor/sensor.h"
|
||||||
#include "esphome/components/text_sensor/text_sensor.h"
|
#include "esphome/components/text_sensor/text_sensor.h"
|
||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
@@ -56,12 +57,14 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
|||||||
SUB_SENSOR(charging_time)
|
SUB_SENSOR(charging_time)
|
||||||
SUB_SENSOR(charger_state)
|
SUB_SENSOR(charger_state)
|
||||||
SUB_SENSOR(max_current)
|
SUB_SENSOR(max_current)
|
||||||
|
SUB_NUMBER(max_current) // settable max-current number entity
|
||||||
SUB_TEXT_SENSOR(serial_number)
|
SUB_TEXT_SENSOR(serial_number)
|
||||||
SUB_TEXT_SENSOR(fw_version)
|
SUB_TEXT_SENSOR(fw_version)
|
||||||
SUB_TEXT_SENSOR(bt_fw_version)
|
SUB_TEXT_SENSOR(bt_fw_version)
|
||||||
SUB_TEXT_SENSOR(model_name)
|
SUB_TEXT_SENSOR(model_name)
|
||||||
SUB_TEXT_SENSOR(error_code)
|
SUB_TEXT_SENSOR(error_code)
|
||||||
void set_charging_state(bool state);
|
void set_charging_state(bool state);
|
||||||
|
void set_max_current(float current);
|
||||||
void start_charging();
|
void start_charging();
|
||||||
void stop_charging();
|
void stop_charging();
|
||||||
|
|
||||||
@@ -95,6 +98,7 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
|||||||
uint16_t tx_char_handle_{0};
|
uint16_t tx_char_handle_{0};
|
||||||
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
||||||
uint8_t pairing_attempts_{0};
|
uint8_t pairing_attempts_{0};
|
||||||
|
uint8_t max_current_limit_{0};
|
||||||
bool has_pin_{false};
|
bool has_pin_{false};
|
||||||
bool pairing_in_progress_{false};
|
bool pairing_in_progress_{false};
|
||||||
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import esphome.codegen as cg
|
||||||
|
from esphome.components import number
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import CONF_MAX_CURRENT, ENTITY_CATEGORY_CONFIG, UNIT_AMPERE
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||||
|
|
||||||
|
DEPENDENCIES = ["delta_wallbox"]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||||
|
cv.Optional(CONF_MAX_CURRENT): number.number_schema(
|
||||||
|
number.ApplyNumber,
|
||||||
|
unit_of_measurement=UNIT_AMPERE,
|
||||||
|
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||||
|
icon="mdi:flash",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config: ConfigType) -> None:
|
||||||
|
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||||
|
numbers = number.sub_numbers(config, parent=config[CONF_DELTA_WALLBOX_ID])
|
||||||
|
await numbers(
|
||||||
|
CONF_MAX_CURRENT,
|
||||||
|
hub.set_max_current_number,
|
||||||
|
min_value=6,
|
||||||
|
max_value=32,
|
||||||
|
step=1,
|
||||||
|
apply="set_max_current(value)",
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from esphome import automation, core
|
from esphome import automation, core, pins
|
||||||
from esphome.automation import maybe_simple_id
|
from esphome.automation import maybe_simple_id
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components.const import (
|
from esphome.components.const import (
|
||||||
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
|
|||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_AUTO_CLEAR_ENABLED,
|
CONF_AUTO_CLEAR_ENABLED,
|
||||||
CONF_DIMENSIONS,
|
CONF_DIMENSIONS,
|
||||||
|
CONF_ENABLE_PIN,
|
||||||
CONF_FROM,
|
CONF_FROM,
|
||||||
CONF_HEIGHT,
|
CONF_HEIGHT,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayModel(Protocol):
|
||||||
|
"""The parts of a display model used by enable_pin_schema()."""
|
||||||
|
|
||||||
|
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
def enable_pin_schema(
|
||||||
|
model: DisplayModel, config: ConfigType
|
||||||
|
) -> dict[cv.Optional, Any]:
|
||||||
|
"""
|
||||||
|
Get the schema entry for a display's enable pins.
|
||||||
|
Pins given in the user config are used as they are. Otherwise the model's default enable
|
||||||
|
pins are used, except any that is also used elsewhere in the config, so a user can take
|
||||||
|
control of that pin, e.g. with a switch.
|
||||||
|
:param config: The display config as given by the user
|
||||||
|
"""
|
||||||
|
schema = cv.ensure_list(pins.gpio_output_pin_schema)
|
||||||
|
default = model.get_default(CONF_ENABLE_PIN, None)
|
||||||
|
if default is None or CONF_ENABLE_PIN in config:
|
||||||
|
return {cv.Optional(CONF_ENABLE_PIN): schema}
|
||||||
|
return {
|
||||||
|
cv.Optional(
|
||||||
|
CONF_ENABLE_PIN, default=default
|
||||||
|
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_rotation(value):
|
def validate_rotation(value):
|
||||||
value = cv.string(value)
|
value = cv.string(value)
|
||||||
value = value.removesuffix("°")
|
value = value.removesuffix("°")
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def model_schema(config):
|
|||||||
cv.GenerateID(): cv.declare_id(class_name),
|
cv.GenerateID(): cv.declare_id(class_name),
|
||||||
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
|
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
|
||||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
|
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
|
||||||
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
|
**display.enable_pin_schema(model, config),
|
||||||
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
|
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
|
||||||
map_sequence
|
map_sequence
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
|
|
||||||
|
from esphome.components.mipi import check_model_requirements
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
|
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.cpp_generator import MockObj
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
@@ -128,24 +128,5 @@ class EpaperModel:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
def check_requirements(self) -> None:
|
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.
|
check_model_requirements(self.name, self.defaults)
|
||||||
|
|
||||||
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
|
import esphome.codegen as cg
|
||||||
from esphome.components import climate_ir, remote_base
|
from esphome.components import climate_ir, remote_base
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_MODEL, CONF_SENSOR
|
from esphome.const import CONF_MODEL, CONF_SENSOR, UNIT_FAHRENHEIT
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
AUTO_LOAD = ["climate_ir"]
|
AUTO_LOAD = ["climate_ir"]
|
||||||
@@ -13,7 +13,10 @@ FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
|
|||||||
Model = friedrich_ns.enum("Model", is_class=True)
|
Model = friedrich_ns.enum("Model", is_class=True)
|
||||||
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
|
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
|
||||||
|
|
||||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
|
# The unit reports in Fahrenheit (traits() in friedrich.cpp), so visual temperatures are kept in it
|
||||||
|
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||||
|
FriedrichClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||||
|
).extend(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
|
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
|
||||||
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
|
# 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 import climate_ir
|
||||||
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
|
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_SENSOR
|
from esphome.const import CONF_SENSOR, UNIT_FAHRENHEIT
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
AUTO_LOAD = ["climate_ir"]
|
AUTO_LOAD = ["climate_ir"]
|
||||||
@@ -11,7 +11,10 @@ CODEOWNERS = ["@ipopov"]
|
|||||||
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
||||||
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
||||||
|
|
||||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
|
# The unit reports in Fahrenheit (traits() in islandaire.cpp), so visual temperatures are kept in it
|
||||||
|
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||||
|
IslandaireClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||||
|
).extend(
|
||||||
{
|
{
|
||||||
# The unit has no auto (heat/cool) mode
|
# The unit has no auto (heat/cool) mode
|
||||||
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
||||||
|
|||||||
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
|
|||||||
cv.Optional(CONF_UPDATE_MODE): update_mode,
|
cv.Optional(CONF_UPDATE_MODE): update_mode,
|
||||||
# One or more GPIOs driven high during setup to power on the panel
|
# One or more GPIOs driven high during setup to power on the panel
|
||||||
# (e.g. board power-enable rails), before reset and init.
|
# (e.g. board power-enable rails), before reset and init.
|
||||||
cv.Optional(
|
**display.enable_pin_schema(model, config),
|
||||||
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.Optional(CONF_RESET_DURATION): cv.All(
|
||||||
cv.positive_time_period_milliseconds,
|
cv.positive_time_period_milliseconds,
|
||||||
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from esphome import automation, pins
|
from esphome import automation, pins
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
@@ -15,6 +17,8 @@ from esphome.core import CORE
|
|||||||
from esphome.cpp_helpers import gpio_pin_expression
|
from esphome.cpp_helpers import gpio_pin_expression
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CODEOWNERS = ["@max246"]
|
CODEOWNERS = ["@max246"]
|
||||||
DOMAIN = "lightwaverf"
|
DOMAIN = "lightwaverf"
|
||||||
|
|
||||||
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
|
|||||||
).extend(cv.polling_component_schema("1s"))
|
).extend(cv.polling_component_schema("1s"))
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_unused_name(config: ConfigType) -> ConfigType:
|
||||||
|
# Remove before 2027.5.0
|
||||||
|
if config.pop(CONF_NAME, None) is not None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
|
||||||
|
"Removed in 2027.5.0"
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
|
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
|
||||||
cv.Required(CONF_NAME): cv.string,
|
cv.Optional(CONF_NAME): cv.string,
|
||||||
cv.Required(CONF_CODE): cv.All(
|
cv.Required(CONF_CODE): cv.All(
|
||||||
[cv.Any(cv.hex_uint8_t)],
|
[cv.Any(cv.hex_uint8_t)],
|
||||||
cv.Length(min=10),
|
cv.Length(min=10),
|
||||||
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
|
|||||||
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
|
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
|
||||||
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
|
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
|
||||||
}
|
}
|
||||||
)
|
).add_extra(_drop_unused_name)
|
||||||
|
|
||||||
|
|
||||||
automation.register_apply_action(
|
automation.register_apply_action(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from esphome.const import (
|
|||||||
CONF_X,
|
CONF_X,
|
||||||
CONF_Y,
|
CONF_Y,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
from esphome.cpp_types import FixedVector
|
from esphome.cpp_types import FixedVector
|
||||||
|
|
||||||
from ..automation import action_to_code
|
from ..automation import action_to_code
|
||||||
@@ -41,6 +42,7 @@ from ..defines import (
|
|||||||
CONF_RADIUS,
|
CONF_RADIUS,
|
||||||
CONF_SRC,
|
CONF_SRC,
|
||||||
CONF_START_ANGLE,
|
CONF_START_ANGLE,
|
||||||
|
LValidator,
|
||||||
addr,
|
addr,
|
||||||
literal,
|
literal,
|
||||||
)
|
)
|
||||||
@@ -57,7 +59,7 @@ from ..lv_validation import (
|
|||||||
)
|
)
|
||||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
||||||
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
||||||
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
|
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
|
||||||
from . import Widget, WidgetType, get_widgets
|
from . import Widget, WidgetType, get_widgets
|
||||||
from .img import CONF_IMAGE
|
from .img import CONF_IMAGE
|
||||||
|
|
||||||
@@ -221,7 +223,6 @@ RECT_PROPS = {
|
|||||||
"radius",
|
"radius",
|
||||||
"bg_opa",
|
"bg_opa",
|
||||||
"bg_color",
|
"bg_color",
|
||||||
"bg_grad",
|
|
||||||
"border_color",
|
"border_color",
|
||||||
"border_width",
|
"border_width",
|
||||||
"border_opa",
|
"border_opa",
|
||||||
@@ -239,6 +240,30 @@ RECT_PROPS = {
|
|||||||
"shadow_opa",
|
"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):
|
def _draw_line(layer, dsc, points):
|
||||||
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
|
|||||||
ObjUpdateAction,
|
ObjUpdateAction,
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
**DRAW_OPA_SCHEMA,
|
**DRAW_SCHEMA,
|
||||||
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
||||||
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
||||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
**RECT_SCHEMA,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
|
|||||||
LINE_PROPS = {
|
LINE_PROPS = {
|
||||||
"width": STYLE_PROPS["line_width"],
|
"width": STYLE_PROPS["line_width"],
|
||||||
"color": STYLE_PROPS["line_color"],
|
"color": STYLE_PROPS["line_color"],
|
||||||
"dash-width": STYLE_PROPS["line_dash_width"],
|
"dash_width": STYLE_PROPS["line_dash_width"],
|
||||||
"dash-gap": STYLE_PROPS["line_dash_gap"],
|
"dash_gap": STYLE_PROPS["line_dash_gap"],
|
||||||
"round_start": lv_bool,
|
"round_start": lv_bool,
|
||||||
"round_end": lv_bool,
|
"round_end": lv_bool,
|
||||||
}
|
}
|
||||||
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
|
|||||||
{
|
{
|
||||||
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
|
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
|
||||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
**POLYGON_SCHEMA,
|
||||||
},
|
},
|
||||||
).add_extra(_validate_points),
|
).add_extra(_validate_points),
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
|
|||||||
_draw_line(layer, line_dsc, points)
|
_draw_line(layer, line_dsc, points)
|
||||||
|
|
||||||
return await draw_to_code(
|
return await draw_to_code(
|
||||||
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
|
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from esphome.const import (
|
|||||||
CONF_WIDTH,
|
CONF_WIDTH,
|
||||||
CONF_X,
|
CONF_X,
|
||||||
)
|
)
|
||||||
|
from esphome.core import ID
|
||||||
from esphome.cpp_generator import MockObj
|
from esphome.cpp_generator import MockObj
|
||||||
from esphome.cpp_types import nullptr
|
from esphome.cpp_types import nullptr
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ from ..lv_validation import (
|
|||||||
)
|
)
|
||||||
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
|
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
|
||||||
from ..schemas import STATE_SCHEMA
|
from ..schemas import STATE_SCHEMA
|
||||||
from ..styles import LVStyle
|
from ..styles import LVStyle, create_style
|
||||||
from ..types import (
|
from ..types import (
|
||||||
LV_EVENT,
|
LV_EVENT,
|
||||||
LvCompound,
|
LvCompound,
|
||||||
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
|
|||||||
CONF_STRIDE = "stride"
|
CONF_STRIDE = "stride"
|
||||||
CONF_TICK_STYLE = "tick_style"
|
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
|
# 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
|
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
|
||||||
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
|
|||||||
get_warnings().add(
|
get_warnings().add(
|
||||||
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
|
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
|
||||||
)
|
)
|
||||||
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
|
style_var = await create_style(_arc_style_name(iid.id), props)
|
||||||
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
|
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
|
||||||
lv.scale_section_set_style(
|
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
|
||||||
tvar, LV_PART.MAIN, await arc_style.get_var()
|
|
||||||
)
|
|
||||||
lw = Widget.create(iid, tvar, arc_indicator_type)
|
lw = Widget.create(iid, tvar, arc_indicator_type)
|
||||||
lw.parent = scale_var
|
lw.parent = scale_var
|
||||||
await set_indicator_values(scale_var, lw, v)
|
await set_indicator_values(scale_var, lw, v)
|
||||||
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
|
|||||||
|
|
||||||
async def set_value(w: Widget):
|
async def set_value(w: Widget):
|
||||||
await set_indicator_values(w.parent, w, config)
|
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)
|
return await action_to_code(widget, set_value, action_id, template_arg, args)
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
import functools
|
import functools
|
||||||
|
import importlib.util
|
||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from esphome import pins
|
||||||
from esphome.components.const import CONF_COLOR_DEPTH
|
from esphome.components.const import CONF_COLOR_DEPTH
|
||||||
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
|
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
@@ -23,6 +25,7 @@ from esphome.const import (
|
|||||||
CONF_MIRROR_X,
|
CONF_MIRROR_X,
|
||||||
CONF_MIRROR_Y,
|
CONF_MIRROR_Y,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
|
CONF_NUMBER,
|
||||||
CONF_OFFSET_HEIGHT,
|
CONF_OFFSET_HEIGHT,
|
||||||
CONF_OFFSET_WIDTH,
|
CONF_OFFSET_WIDTH,
|
||||||
CONF_PAGES,
|
CONF_PAGES,
|
||||||
@@ -306,6 +309,57 @@ def model_schema_extractor(
|
|||||||
return decorate
|
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:
|
class DriverChip:
|
||||||
"""
|
"""
|
||||||
A class representing a MIPI DBI driver chip model.
|
A class representing a MIPI DBI driver chip model.
|
||||||
@@ -671,32 +725,14 @@ class DriverChip:
|
|||||||
|
|
||||||
def check_requirements(self) -> None:
|
def check_requirements(self) -> None:
|
||||||
"""
|
"""
|
||||||
Raise a friendly error if any component this model requires is not configured.
|
Check that the components this model needs are configured, see check_model_requirements().
|
||||||
|
|
||||||
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.
|
Also logs a warning if the model is deprecated.
|
||||||
"""
|
"""
|
||||||
if deprecation_reason := self.get_default("deprecation_reason"):
|
if deprecation_reason := self.get_default("deprecation_reason"):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
||||||
)
|
)
|
||||||
if requirements := self.get_default("requires", set()):
|
check_model_requirements(self.name, self.defaults)
|
||||||
# ``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:
|
def requires_buffer(config) -> bool:
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def model_schema(config: ConfigType) -> cv.All:
|
def model_schema(config: ConfigType) -> cv.Schema:
|
||||||
model = MODELS[config[CONF_MODEL].upper()]
|
model = MODELS[config[CONF_MODEL].upper()]
|
||||||
transform = model.transform_schema()
|
transform = model.transform_schema()
|
||||||
# CUSTOM model will need to provide a custom init sequence
|
# CUSTOM model will need to provide a custom init sequence
|
||||||
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
|
|||||||
# Dimensions are optional if the model has a default width
|
# Dimensions are optional if the model has a default width
|
||||||
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
|
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
|
||||||
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
|
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
|
||||||
schema = display.FULL_DISPLAY_SCHEMA.extend(
|
return display.FULL_DISPLAY_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||||
cv.GenerateID(): cv.declare_id(MipiDsi),
|
cv.GenerateID(): cv.declare_id(MipiDsi),
|
||||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||||
),
|
),
|
||||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
**display.enable_pin_schema(model, config),
|
||||||
pins.gpio_output_pin_schema
|
|
||||||
),
|
|
||||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
|
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_DRAW_ROUNDING, 2): power_of_two,
|
||||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||||
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
|
|||||||
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
|
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)
|
@model_schema_extractor(MODELS, model_schema)
|
||||||
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
|||||||
},
|
},
|
||||||
extra=cv.ALLOW_EXTRA,
|
extra=cv.ALLOW_EXTRA,
|
||||||
)(config)
|
)(config)
|
||||||
config = model_schema(config)(config)
|
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
# Check before the schema, which would fail on default pins of a missing expander
|
||||||
model = MODELS[config[CONF_MODEL].upper()]
|
model = MODELS[config[CONF_MODEL].upper()]
|
||||||
model.check_requirements()
|
model.check_requirements()
|
||||||
|
config = model_schema(config)(config)
|
||||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||||
model.get_dimensions(config)
|
model.get_dimensions(config)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ DsiDriverChip(
|
|||||||
pclk_frequency="60MHz",
|
pclk_frequency="60MHz",
|
||||||
lane_bit_rate="730Mbps",
|
lane_bit_rate="730Mbps",
|
||||||
color_order="RGB",
|
color_order="RGB",
|
||||||
requires={"psram", "pi4ioe5v6408"},
|
requires={"psram"},
|
||||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||||
initsequence=[
|
initsequence=[
|
||||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||||
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
|
|||||||
pclk_frequency="80MHz",
|
pclk_frequency="80MHz",
|
||||||
lane_bit_rate="960Mbps",
|
lane_bit_rate="960Mbps",
|
||||||
color_order="RGB",
|
color_order="RGB",
|
||||||
requires={"psram", "pi4ioe5v6408"},
|
requires={"psram"},
|
||||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||||
initsequence=[
|
initsequence=[
|
||||||
(0x01,),
|
(0x01,),
|
||||||
@@ -122,7 +122,7 @@ DsiDriverChip(
|
|||||||
pclk_frequency="70MHz",
|
pclk_frequency="70MHz",
|
||||||
lane_bit_rate="965Mbps",
|
lane_bit_rate="965Mbps",
|
||||||
color_order="RGB",
|
color_order="RGB",
|
||||||
requires={"psram", "pi4ioe5v6408"},
|
requires={"psram"},
|
||||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||||
initsequence=[
|
initsequence=[
|
||||||
(0x01,),
|
(0x01,),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ DsiDriverChip(
|
|||||||
color_order="RGB",
|
color_order="RGB",
|
||||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||||
reset_pin={"xl9535": None, "number": 2},
|
reset_pin={"xl9535": None, "number": 2},
|
||||||
requires={"psram", "xl9535"},
|
requires={"psram"},
|
||||||
initsequence=(
|
initsequence=(
|
||||||
(0xE0, 0x00),
|
(0xE0, 0x00),
|
||||||
(0xE1, 0x93),
|
(0xE1, 0x93),
|
||||||
|
|||||||
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
|||||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||||
),
|
),
|
||||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
**display.enable_pin_schema(model, config),
|
||||||
pins.gpio_output_pin_schema
|
|
||||||
),
|
|
||||||
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
|
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_DRAW_ROUNDING, 2): power_of_two,
|
||||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||||
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
|||||||
},
|
},
|
||||||
extra=cv.ALLOW_EXTRA,
|
extra=cv.ALLOW_EXTRA,
|
||||||
)(config)
|
)(config)
|
||||||
schema = model_schema(config)
|
|
||||||
config = cv.All(
|
config = cv.All(
|
||||||
schema,
|
|
||||||
cv.only_on_esp32,
|
cv.only_on_esp32,
|
||||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
|
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
|
||||||
)(config)
|
)(config)
|
||||||
|
# Check before the schema, which would fail on default pins of a missing expander
|
||||||
model = MODELS[config[CONF_MODEL].upper()]
|
model = MODELS[config[CONF_MODEL].upper()]
|
||||||
model.check_requirements()
|
model.check_requirements()
|
||||||
|
config = model_schema(config)(config)
|
||||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||||
model.get_dimensions(config)
|
model.get_dimensions(config)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ ST7701S(
|
|||||||
spi_mode="MODE3",
|
spi_mode="MODE3",
|
||||||
cs_pin={"xl9535": None, "number": 17},
|
cs_pin={"xl9535": None, "number": 17},
|
||||||
reset_pin={"xl9535": None, "number": 5},
|
reset_pin={"xl9535": None, "number": 5},
|
||||||
requires={"psram", "xl9535"},
|
requires={"psram"},
|
||||||
hsync_pin=39,
|
hsync_pin=39,
|
||||||
vsync_pin=40,
|
vsync_pin=40,
|
||||||
pclk_pin=41,
|
pclk_pin=41,
|
||||||
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
|
|||||||
invert_colors=False,
|
invert_colors=False,
|
||||||
spi_mode="MODE3",
|
spi_mode="MODE3",
|
||||||
cs_pin={"xl9535": None, "number": 3},
|
cs_pin={"xl9535": None, "number": 3},
|
||||||
requires={"psram", "xl9535"},
|
requires={"psram"},
|
||||||
de_pin=45,
|
de_pin=45,
|
||||||
hsync_pin=47,
|
hsync_pin=47,
|
||||||
vsync_pin=41,
|
vsync_pin=41,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ st7701s.extend(
|
|||||||
vsync_pulse_width=8,
|
vsync_pulse_width=8,
|
||||||
vsync_back_porch=20,
|
vsync_back_porch=20,
|
||||||
cs_pin={"pca9554": None, "number": 4},
|
cs_pin={"pca9554": None, "number": 4},
|
||||||
requires={"psram", "pca9554"},
|
requires={"psram"},
|
||||||
de_pin=18,
|
de_pin=18,
|
||||||
hsync_pin=16,
|
hsync_pin=16,
|
||||||
vsync_pin=17,
|
vsync_pin=17,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
|
|||||||
pclk_frequency="16MHz",
|
pclk_frequency="16MHz",
|
||||||
reset_pin={"ch422g": None, "number": 3},
|
reset_pin={"ch422g": None, "number": 3},
|
||||||
enable_pin={"ch422g": None, "number": 2},
|
enable_pin={"ch422g": None, "number": 2},
|
||||||
requires={"psram", "ch422g"},
|
requires={"psram"},
|
||||||
de_pin=5,
|
de_pin=5,
|
||||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||||
|
|||||||
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
|
|||||||
{
|
{
|
||||||
cv.GenerateID(): cv.declare_id(MipiSpi),
|
cv.GenerateID(): cv.declare_id(MipiSpi),
|
||||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
||||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
**display.enable_pin_schema(model, config),
|
||||||
pins.gpio_output_pin_schema
|
|
||||||
),
|
|
||||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
||||||
COLOR_ORDERS, upper=True
|
COLOR_ORDERS, upper=True
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ DriverChip(
|
|||||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||||
use_axis_flips=True,
|
use_axis_flips=True,
|
||||||
reset_delay=20,
|
reset_delay=20,
|
||||||
requires={"pca9554"},
|
|
||||||
initsequence=(
|
initsequence=(
|
||||||
delay(100),
|
delay(100),
|
||||||
(0xFF, 0x20, 0x10, 0x10),
|
(0xFF, 0x20, 0x10, 0x10),
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ from esphome.const import (
|
|||||||
CONF_TEMPERATURE,
|
CONF_TEMPERATURE,
|
||||||
CONF_UART_ID,
|
CONF_UART_ID,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
|
CONF_USE_FAHRENHEIT,
|
||||||
|
UNIT_CELSIUS,
|
||||||
|
UNIT_FAHRENHEIT,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
import esphome.final_validate as fv
|
||||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
@@ -69,7 +73,17 @@ def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
|
def _temperature_unit(config: ConfigType, full_config: ConfigType) -> str:
|
||||||
|
# The hub's use_fahrenheit picks the unit in traits(); a legacy climate-owned hub has no option
|
||||||
|
if (hub_id := config.get(CONF_MITSUBISHI_CN105_ID)) is None:
|
||||||
|
return UNIT_CELSIUS
|
||||||
|
hub = fv.get_declaring_config(hub_id, full_config)
|
||||||
|
return UNIT_FAHRENHEIT if hub.get(CONF_USE_FAHRENHEIT) else UNIT_CELSIUS
|
||||||
|
|
||||||
|
|
||||||
|
_BASE_SCHEMA = climate.climate_schema(
|
||||||
|
MitsubishiCN105Climate, fixed_temperature_unit=_temperature_unit
|
||||||
|
).extend(
|
||||||
{
|
{
|
||||||
cv.Optional(
|
cv.Optional(
|
||||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation, preferences
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import logger, network, socket
|
from esphome.components import logger, network, socket
|
||||||
from esphome.components.const import CONF_VERIFY_SSL
|
from esphome.components.const import CONF_VERIFY_SSL
|
||||||
@@ -107,6 +107,7 @@ def AUTO_LOAD():
|
|||||||
CONF_TLS = "tls"
|
CONF_TLS = "tls"
|
||||||
CONF_IDF_SEND_ASYNC = "idf_send_async"
|
CONF_IDF_SEND_ASYNC = "idf_send_async"
|
||||||
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
|
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
|
||||||
|
CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions"
|
||||||
CONF_TRANSPORT = "transport"
|
CONF_TRANSPORT = "transport"
|
||||||
CONF_WS_PATH = "ws_path"
|
CONF_WS_PATH = "ws_path"
|
||||||
|
|
||||||
@@ -304,6 +305,10 @@ def validate_config(value):
|
|||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
f"'{CONF_WS_PATH}' is only valid when 'transport' is 'ws' or 'wss'"
|
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
|
# Populate default fields
|
||||||
out = value.copy()
|
out = value.copy()
|
||||||
@@ -394,6 +399,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Optional(CONF_USERNAME, default=""): cv.string,
|
cv.Optional(CONF_USERNAME, default=""): cv.string,
|
||||||
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
|
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
|
||||||
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
|
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_CLIENT_ID): cv.string,
|
||||||
cv.Optional(
|
cv.Optional(
|
||||||
CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP
|
CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP
|
||||||
@@ -666,6 +672,10 @@ async def to_code(config):
|
|||||||
cg.add(var.set_username(config[CONF_USERNAME]))
|
cg.add(var.set_username(config[CONF_USERNAME]))
|
||||||
cg.add(var.set_password(config[CONF_PASSWORD]))
|
cg.add(var.set_password(config[CONF_PASSWORD]))
|
||||||
cg.add(var.set_clean_session(config[CONF_CLEAN_SESSION]))
|
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))
|
cg.add_optional(var.set_client_id, config.get(CONF_CLIENT_ID))
|
||||||
|
|
||||||
discovery = config[CONF_DISCOVERY]
|
discovery = config[CONF_DISCOVERY]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class MQTTBackend {
|
|||||||
public:
|
public:
|
||||||
using on_connect_callback_t = void(bool session_present);
|
using on_connect_callback_t = void(bool session_present);
|
||||||
using on_disconnect_callback_t = void(MQTTClientDisconnectReason reason);
|
using on_disconnect_callback_t = void(MQTTClientDisconnectReason reason);
|
||||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t qos);
|
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t return_code);
|
||||||
using on_unsubscribe_callback_t = void(uint16_t packet_id);
|
using on_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_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);
|
using on_publish_user_callback_t = void(uint16_t packet_id);
|
||||||
|
|||||||
@@ -170,8 +170,9 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) {
|
|||||||
|
|
||||||
case MQTT_EVENT_SUBSCRIBED:
|
case MQTT_EVENT_SUBSCRIBED:
|
||||||
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
|
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
|
||||||
// hardcode QoS to 0. QoS is not used in this context but required to mirror the AsyncMqtt interface
|
// One topic per SUBSCRIBE, so data[0] is its return code
|
||||||
this->on_subscribe_.call((int) event.msg_id, 0);
|
this->on_subscribe_.call((int) event.msg_id,
|
||||||
|
event.data.empty() ? SUBACK_FAILURE : static_cast<uint8_t>(event.data[0]));
|
||||||
break;
|
break;
|
||||||
case MQTT_EVENT_UNSUBSCRIBED:
|
case MQTT_EVENT_UNSUBSCRIBED:
|
||||||
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
|
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
|
||||||
@@ -224,6 +225,16 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b
|
|||||||
if (msg_id == instance->tracked_msg_id_.load())
|
if (msg_id == instance->tracked_msg_id_.load())
|
||||||
instance->tracked_acked_.store(1);
|
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
|
// queue event to decouple processing from ESP-IDF MQTT task to main loop
|
||||||
auto *event = instance->mqtt_event_pool_.allocate();
|
auto *event = instance->mqtt_event_pool_.allocate();
|
||||||
if (event == nullptr) {
|
if (event == nullptr) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
#include "esphome/core/lock_free_queue.h"
|
#include "esphome/core/lock_free_queue.h"
|
||||||
#include "esphome/core/event_pool.h"
|
#include "esphome/core/event_pool.h"
|
||||||
|
#include "mqtt_subscription_fingerprint.h"
|
||||||
|
|
||||||
namespace esphome::mqtt {
|
namespace esphome::mqtt {
|
||||||
|
|
||||||
@@ -214,6 +215,9 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
|||||||
using MQTTBackend::publish;
|
using MQTTBackend::publish;
|
||||||
bool publish_tracked(const MQTTMessage &message) final;
|
bool publish_tracked(const MQTTMessage &message) final;
|
||||||
bool publish_acknowledged() const final { return this->tracked_acked_.load(std::memory_order_acquire) != 0; }
|
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;
|
void loop() final;
|
||||||
|
|
||||||
@@ -294,6 +298,10 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
|||||||
std::atomic<uint16_t> acked_msg_id_{0};
|
std::atomic<uint16_t> acked_msg_id_{0};
|
||||||
// uint8_t, not bool: GCC on Xtensa emits an out-of-line call for atomic<bool>
|
// uint8_t, not bool: GCC on Xtensa emits an out-of-line call for atomic<bool>
|
||||||
std::atomic<uint8_t> tracked_acked_{0};
|
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
|
// 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).
|
// buffer that holds N-1 elements (one slot distinguishes full from empty).
|
||||||
// This guarantees allocate() returns nullptr before push() can fail, which:
|
// This guarantees allocate() returns nullptr before push() can fail, which:
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
#include "esphome/components/api/api_server.h"
|
#include "esphome/components/api/api_server.h"
|
||||||
#endif
|
#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
|
#ifdef USE_DASHBOARD_IMPORT
|
||||||
#include "esphome/components/dashboard_import/dashboard_import.h"
|
#include "esphome/components/dashboard_import/dashboard_import.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -32,6 +42,39 @@ ESPHOME_LOG_TAG(TAG, "mqtt");
|
|||||||
// Maximum number of MQTT component resends per loop iteration.
|
// Maximum number of MQTT component resends per loop iteration.
|
||||||
// Limits work to avoid triggering the task watchdog on reconnect.
|
// Limits work to avoid triggering the task watchdog on reconnect.
|
||||||
static constexpr uint8_t MAX_RESENDS_PER_LOOP = 8;
|
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)
|
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
|
||||||
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
|
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
|
||||||
@@ -80,6 +123,19 @@ void MQTTClientComponent::setup() {
|
|||||||
this->state_ = MQTT_CLIENT_DISCONNECTED;
|
this->state_ = MQTT_CLIENT_DISCONNECTED;
|
||||||
this->disconnect_reason_ = reason;
|
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
|
#ifdef USE_LOGGER
|
||||||
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
|
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
|
||||||
logger::global_logger->add_log_callback(
|
logger::global_logger->add_log_callback(
|
||||||
@@ -361,6 +417,9 @@ void MQTTClientComponent::start_connect_() {
|
|||||||
this->last_will_.payload.c_str());
|
this->last_will_.payload.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||||
|
this->session_present_ = false;
|
||||||
|
#endif
|
||||||
this->mqtt_backend_.connect();
|
this->mqtt_backend_.connect();
|
||||||
this->state_ = MQTT_CLIENT_CONNECTING;
|
this->state_ = MQTT_CLIENT_CONNECTING;
|
||||||
this->connect_begin_ = millis();
|
this->connect_begin_ = millis();
|
||||||
@@ -385,14 +444,30 @@ void MQTTClientComponent::check_connected() {
|
|||||||
// MQTT Client needs some time to be fully set up.
|
// MQTT Client needs some time to be fully set up.
|
||||||
delay(100); // NOLINT
|
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_();
|
this->resubscribe_subscriptions_();
|
||||||
#ifdef USE_MQTT_CRASH_LOG
|
#ifdef USE_MQTT_CRASH_LOG
|
||||||
// Best effort: a refused subscription is tried again at the next connection
|
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||||
if (this->crash_log_offered_) {
|
// Otherwise sent once the SUBACKs are in, so its own SUBACK is never counted
|
||||||
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
|
if (resumed) {
|
||||||
format_esphome_topic(topic, "logs");
|
this->subscribe_crash_log_();
|
||||||
this->subscribe_(topic, 0);
|
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
this->subscribe_crash_log_();
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
this->send_device_info_();
|
this->send_device_info_();
|
||||||
|
|
||||||
@@ -441,7 +516,12 @@ void MQTTClientComponent::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this->last_connected_ = now;
|
this->last_connected_ = now;
|
||||||
|
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||||
|
if (this->resubscribe_subscriptions_() && !this->subacks_settled_)
|
||||||
|
this->check_subacks_();
|
||||||
|
#else
|
||||||
this->resubscribe_subscriptions_();
|
this->resubscribe_subscriptions_();
|
||||||
|
#endif
|
||||||
|
|
||||||
// Process pending resends for all MQTT components centrally
|
// Process pending resends for all MQTT components centrally
|
||||||
// Limit work per loop iteration to avoid triggering task WDT on reconnect
|
// Limit work per loop iteration to avoid triggering task WDT on reconnect
|
||||||
@@ -499,15 +579,69 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
|
|||||||
if (do_resub) {
|
if (do_resub) {
|
||||||
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
|
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
|
||||||
sub->resubscribe_timeout = now;
|
sub->resubscribe_timeout = now;
|
||||||
|
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||||
|
// Only the subscriptions the fingerprint covers are counted
|
||||||
|
if (sub->subscribed)
|
||||||
|
this->subscribes_sent_++;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void MQTTClientComponent::resubscribe_subscriptions_() {
|
bool MQTTClientComponent::resubscribe_subscriptions_() {
|
||||||
|
bool all_subscribed = true;
|
||||||
for (auto &subscription : this->subscriptions_) {
|
for (auto &subscription : this->subscriptions_) {
|
||||||
this->resubscribe_subscription_(&subscription);
|
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
|
#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) {
|
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
|
// 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
|
// log for every line it logs
|
||||||
@@ -572,6 +706,10 @@ __attribute__((noinline)) static bool topic_equals(const MQTTTopicSource &source
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MQTTClientComponent::unsubscribe(const std::string &topic) {
|
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());
|
bool ret = this->mqtt_backend_.unsubscribe(topic.c_str());
|
||||||
yield();
|
yield();
|
||||||
if (ret) {
|
if (ret) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
#elif defined(USE_LIBRETINY)
|
#elif defined(USE_LIBRETINY)
|
||||||
#include "mqtt_backend_libretiny.h"
|
#include "mqtt_backend_libretiny.h"
|
||||||
#endif
|
#endif
|
||||||
|
#if defined(USE_MQTT_SESSION_PERSISTENCE) && !defined(USE_ESP32)
|
||||||
|
#include "mqtt_subscription_fingerprint.h"
|
||||||
|
#endif
|
||||||
#include "lwip/ip_addr.h"
|
#include "lwip/ip_addr.h"
|
||||||
|
|
||||||
#include <span>
|
#include <span>
|
||||||
@@ -380,10 +383,18 @@ class MQTTClientComponent final : public Component {
|
|||||||
}
|
}
|
||||||
void erase_removed_subscriptions_();
|
void erase_removed_subscriptions_();
|
||||||
#ifdef USE_MQTT_CRASH_LOG
|
#ifdef USE_MQTT_CRASH_LOG
|
||||||
|
void subscribe_crash_log_();
|
||||||
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
|
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
|
||||||
#endif
|
#endif
|
||||||
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
|
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
|
||||||
void resubscribe_subscriptions_();
|
/// Returns true when every subscription is subscribed
|
||||||
|
bool resubscribe_subscriptions_();
|
||||||
|
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||||
|
uint32_t subscriptions_fingerprint_();
|
||||||
|
/// Once every SUBACK of this connection is in, saves the fingerprint if all were granted
|
||||||
|
void check_subacks_();
|
||||||
|
void set_session_fingerprint_(uint32_t fingerprint);
|
||||||
|
#endif
|
||||||
|
|
||||||
MQTTCredentials credentials_;
|
MQTTCredentials credentials_;
|
||||||
/// The last will message. Disabled optional denotes it being default and
|
/// The last will message. Disabled optional denotes it being default and
|
||||||
@@ -440,6 +451,18 @@ class MQTTClientComponent final : public Component {
|
|||||||
bool wait_for_connection_{false};
|
bool wait_for_connection_{false};
|
||||||
bool dispatching_{false};
|
bool dispatching_{false};
|
||||||
bool removed_while_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)
|
extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "esphome/core/helpers.h"
|
||||||
|
|
||||||
|
namespace esphome::mqtt {
|
||||||
|
|
||||||
|
/// Adds one subscription to a fingerprint of a set of subscriptions; the order they are added in does not matter.
|
||||||
|
inline uint32_t add_subscription_fingerprint(uint32_t fingerprint, const char *topic, uint8_t qos) {
|
||||||
|
return fingerprint + fnv1a_hash_extend(fnv1a_hash(topic), qos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session fingerprint while a rebuild is in progress, and after a power loss or a cold boot.
|
||||||
|
static constexpr uint32_t UNTRUSTED_SESSION_FINGERPRINT = 0;
|
||||||
|
|
||||||
|
/// True when a resumed session holds in full the subscriptions it is trusted with, and they are the current ones.
|
||||||
|
constexpr bool session_has_subscriptions(bool session_present, uint32_t trusted, uint32_t current) {
|
||||||
|
return session_present && trusted != UNTRUSTED_SESSION_FINGERPRINT && trusted == current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First SUBACK return code that refuses a subscription; also stands in for a SUBACK with no return code.
|
||||||
|
static constexpr uint8_t SUBACK_FAILURE = 0x80;
|
||||||
|
|
||||||
|
/// True when a SUBACK return code grants the subscription.
|
||||||
|
constexpr bool subscribe_granted(uint8_t return_code) { return return_code < SUBACK_FAILURE; }
|
||||||
|
|
||||||
|
enum class SubackTally : uint8_t {
|
||||||
|
SUBACK_TALLY_PENDING,
|
||||||
|
SUBACK_TALLY_ALL_GRANTED,
|
||||||
|
SUBACK_TALLY_NOT_ALL_GRANTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// SUBACKs received on the current connection. Written only by the context that delivers broker events (so plain
|
||||||
|
/// loads and stores, no read-modify-write), read by the main loop.
|
||||||
|
class SubackCounter {
|
||||||
|
public:
|
||||||
|
void reset() {
|
||||||
|
this->received_.store(0, std::memory_order_relaxed);
|
||||||
|
this->granted_.store(0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
void add(uint8_t return_code) {
|
||||||
|
if (subscribe_granted(return_code))
|
||||||
|
this->granted_.store(this->granted_.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||||
|
// Released after granted_, so a reader that sees this SUBACK in received_ also sees it in granted_
|
||||||
|
this->received_.store(this->received_.load(std::memory_order_relaxed) + 1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
/// Compares the SUBACKs received with the number of SUBSCRIBEs sent on this connection.
|
||||||
|
SubackTally tally(uint16_t sent) const {
|
||||||
|
const uint16_t received = this->received_.load(std::memory_order_acquire);
|
||||||
|
if (received < sent)
|
||||||
|
return SubackTally::SUBACK_TALLY_PENDING;
|
||||||
|
// More than sent means a SUBACK from an earlier connection was counted, so none can be trusted
|
||||||
|
return received == sent && this->granted_.load(std::memory_order_relaxed) == sent
|
||||||
|
? SubackTally::SUBACK_TALLY_ALL_GRANTED
|
||||||
|
: SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
std::atomic<uint16_t> received_{0};
|
||||||
|
std::atomic<uint16_t> granted_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::mqtt
|
||||||
@@ -31,6 +31,7 @@ CONF_PRECISION = "precision"
|
|||||||
CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake"
|
CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake"
|
||||||
CONF_START_UP_PAGE = "start_up_page"
|
CONF_START_UP_PAGE = "start_up_page"
|
||||||
CONF_STARTUP_OVERRIDE_MS = "startup_override_ms"
|
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_RETRIES = "tft_upload_http_retries"
|
||||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout"
|
CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout"
|
||||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout"
|
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .base_component import (
|
|||||||
CONF_SKIP_CONNECTION_HANDSHAKE,
|
CONF_SKIP_CONNECTION_HANDSHAKE,
|
||||||
CONF_START_UP_PAGE,
|
CONF_START_UP_PAGE,
|
||||||
CONF_STARTUP_OVERRIDE_MS,
|
CONF_STARTUP_OVERRIDE_MS,
|
||||||
|
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
|
||||||
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
||||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
||||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
||||||
@@ -53,10 +54,9 @@ def AUTO_LOAD() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_tft_upload(config):
|
def _validate_tft_upload(config):
|
||||||
if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config and not CORE.is_esp32:
|
for key in (CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX):
|
||||||
raise cv.Invalid(
|
if key in config and not CORE.is_esp32:
|
||||||
f"{CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT} is only available on ESP32"
|
raise cv.Invalid(f"{key} is only available on ESP32")
|
||||||
)
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +105,9 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_START_UP_PAGE): cv.uint8_t,
|
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_RETRIES): cv.int_range(min=1, max=255),
|
||||||
cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All(
|
cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All(
|
||||||
cv.positive_time_period_milliseconds,
|
cv.positive_time_period_milliseconds,
|
||||||
@@ -136,6 +139,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
(
|
(
|
||||||
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
|
||||||
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
CONF_TFT_UPLOAD_HTTP_RETRIES,
|
||||||
|
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
|
||||||
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
|
||||||
),
|
),
|
||||||
CONF_TFT_URL,
|
CONF_TFT_URL,
|
||||||
@@ -244,6 +248,14 @@ async def to_code(config):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TFT upload HTTP TX buffer size (ESP32 only; unset = ESP-IDF default)
|
||||||
|
if CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX in config:
|
||||||
|
cg.add(
|
||||||
|
var.set_tft_upload_http_buffer_size_tx(
|
||||||
|
config[CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# TFT upload HTTP retries (default: 5)
|
# TFT upload HTTP retries (default: 5)
|
||||||
if CONF_TFT_UPLOAD_HTTP_RETRIES in config:
|
if CONF_TFT_UPLOAD_HTTP_RETRIES in config:
|
||||||
cg.add(
|
cg.add(
|
||||||
|
|||||||
@@ -1089,6 +1089,19 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
|||||||
void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; }
|
void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; }
|
||||||
|
|
||||||
#ifdef USE_ESP32
|
#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.
|
* @brief Set the watchdog timeout during TFT upload.
|
||||||
*
|
*
|
||||||
@@ -1573,7 +1586,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
|||||||
uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5)
|
uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5)
|
||||||
|
|
||||||
#ifdef USE_ESP32
|
#ifdef USE_ESP32
|
||||||
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
|
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
|
||||||
|
uint16_t tft_upload_http_buffer_size_tx_{0}; ///< HTTP TX buffer in bytes (0 = ESP-IDF default)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* will request 4096 bytes chunks from the web server
|
* will request 4096 bytes chunks from the web server
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
|
|||||||
.timeout_ms = static_cast<int>(this->tft_upload_http_timeout_),
|
.timeout_ms = static_cast<int>(this->tft_upload_http_timeout_),
|
||||||
.disable_auto_redirect = false,
|
.disable_auto_redirect = false,
|
||||||
.max_redirection_count = 10,
|
.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
|
// Initialize the HTTP client with the configuration
|
||||||
esp_http_client_handle_t http_client = esp_http_client_init(&config);
|
esp_http_client_handle_t http_client = esp_http_client_init(&config);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from esphome.const import (
|
|||||||
CONF_INVERTED,
|
CONF_INVERTED,
|
||||||
CONF_NUMBER,
|
CONF_NUMBER,
|
||||||
CONF_OTA,
|
CONF_OTA,
|
||||||
|
CONF_PLATFORM,
|
||||||
CONF_RESET_PIN,
|
CONF_RESET_PIN,
|
||||||
CONF_SAFE_MODE,
|
CONF_SAFE_MODE,
|
||||||
CONF_TOOLCHAIN,
|
CONF_TOOLCHAIN,
|
||||||
@@ -309,9 +310,8 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
|
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
|
||||||
): cv.Schema(
|
): cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Optional(
|
# Defaults to whether MCUboot is present, see _final_validate
|
||||||
CONF_ENABLE_OTA_ROLLBACK, default=True
|
cv.Optional(CONF_ENABLE_OTA_ROLLBACK): cv.boolean,
|
||||||
): cv.boolean,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -370,7 +370,21 @@ def _final_validate(config):
|
|||||||
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
|
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
|
||||||
)
|
)
|
||||||
|
|
||||||
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
|
# Rollback confirms a freshly flashed image through MCUboot, which is either
|
||||||
|
# the bootloader or, for zephyr_mcumgr, a second stage after it.
|
||||||
|
mcuboot = (
|
||||||
|
config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
|
||||||
|
or "zephyr_mcumgr" in CORE.loaded_integrations
|
||||||
|
)
|
||||||
|
if advanced.get(CONF_ENABLE_OTA_ROLLBACK) and not mcuboot:
|
||||||
|
# Older configs set it explicitly; it never had an effect without MCUboot
|
||||||
|
_LOGGER.warning(
|
||||||
|
"'%s' requires MCUboot, which the '%s' bootloader does not have; ignoring it",
|
||||||
|
CONF_ENABLE_OTA_ROLLBACK,
|
||||||
|
config[KEY_BOOTLOADER],
|
||||||
|
)
|
||||||
|
advanced[CONF_ENABLE_OTA_ROLLBACK] = False
|
||||||
|
if advanced.setdefault(CONF_ENABLE_OTA_ROLLBACK, mcuboot):
|
||||||
# "disabled: false" means safe mode *is* enabled.
|
# "disabled: false" means safe mode *is* enabled.
|
||||||
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
|
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
|
||||||
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
|
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
|
||||||
@@ -834,26 +848,58 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
|||||||
raise EsphomeError(f"Upload failed with result: {result}")
|
raise EsphomeError(f"Upload failed with result: {result}")
|
||||||
return True # Handled: PYOCD upload
|
return True # Handled: PYOCD upload
|
||||||
|
|
||||||
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
|
|
||||||
from .ble_logger import is_ble_address
|
|
||||||
from .ota import smpmgr_scan, smpmgr_upload
|
|
||||||
|
|
||||||
if host == "BLE":
|
|
||||||
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
|
|
||||||
|
|
||||||
if is_ble_address(host):
|
|
||||||
mcumgr_device = host
|
|
||||||
|
|
||||||
if mcumgr_device:
|
if mcumgr_device:
|
||||||
firmware = Path(
|
# MCUboot over USB CDC: the serial port speaks the MCUmgr SMP protocol
|
||||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
asyncio.run(_smpmgr_upload(mcumgr_device))
|
||||||
).resolve()
|
return True # Handled: mcumgr serial upload
|
||||||
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
|
|
||||||
return True # Handled: mcumgr OTA upload
|
# Deferred imports: bleak and friends are heavy, only load for BLE paths
|
||||||
|
from .ble_logger import is_ble_address
|
||||||
|
|
||||||
|
if host == "BLE" or is_ble_address(host):
|
||||||
|
asyncio.run(_upload_over_ble(config, host))
|
||||||
|
return True # Handled: BLE OTA upload
|
||||||
|
|
||||||
return False # Not handled: let caller try default upload methods
|
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:
|
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
|
||||||
address = devices[0]
|
address = devices[0]
|
||||||
from .ble_logger import is_ble_address, logger_connect, logger_scan
|
from .ble_logger import is_ble_address, logger_connect, logger_scan
|
||||||
|
|||||||
@@ -2,24 +2,18 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import sensor
|
from esphome.components import sensor
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_SOURCE_ID
|
from esphome.const import CONF_SOURCE_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import Number, number_ns
|
from .. import Number
|
||||||
|
|
||||||
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
|
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
||||||
CONFIG_SCHEMA = (
|
}
|
||||||
sensor.sensor_schema(NumberSensor)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.extend(cv.COMPONENT_SCHEMA)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
source = await cg.get_variable(config[CONF_SOURCE_ID])
|
var = await sensor.new_sensor(config)
|
||||||
var = await sensor.new_sensor(config, source)
|
# Number has the same float state callback as Sensor
|
||||||
await cg.register_component(var, config)
|
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#include "number_sensor.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::number {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "number.sensor");
|
|
||||||
|
|
||||||
void NumberSensor::setup() {
|
|
||||||
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
|
|
||||||
if (this->source_->has_state())
|
|
||||||
this->publish_state(this->source_->state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
|
|
||||||
|
|
||||||
} // namespace esphome::number
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../number.h"
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/sensor/sensor.h"
|
|
||||||
|
|
||||||
namespace esphome::number {
|
|
||||||
|
|
||||||
class NumberSensor final : public sensor::Sensor, public Component {
|
|
||||||
public:
|
|
||||||
explicit NumberSensor(Number *source) : source_(source) {}
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
Number *source_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::number
|
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
#include <new>
|
||||||
|
|
||||||
#ifdef USE_ESP_IDF
|
#ifdef USE_ESP_IDF
|
||||||
#include "esp_task_wdt.h"
|
#include "esp_task_wdt.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -94,7 +96,38 @@ static int draw_callback(JPEGDRAW *jpeg) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
|
||||||
|
RAMAllocator<JPEGDEC> allocator;
|
||||||
|
JPEGDEC *jpeg = allocator.allocate(1);
|
||||||
|
if (jpeg == nullptr) {
|
||||||
|
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->jpeg_ = new (jpeg) JPEGDEC();
|
||||||
|
}
|
||||||
|
|
||||||
|
JpegDecoder::~JpegDecoder() {
|
||||||
|
if (this->jpeg_ != nullptr) {
|
||||||
|
this->jpeg_->~JPEGDEC();
|
||||||
|
RAMAllocator<JPEGDEC> allocator;
|
||||||
|
allocator.deallocate(this->jpeg_, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int JpegDecoder::prepare(size_t expected_size) {
|
||||||
|
// Check before the base prepare() so a failure never leaves an active session
|
||||||
|
if (this->jpeg_ == nullptr) {
|
||||||
|
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||||
|
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
return ImageDecoder::prepare(expected_size);
|
||||||
|
}
|
||||||
|
|
||||||
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||||
|
if (this->jpeg_ == nullptr) {
|
||||||
|
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||||
|
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
// JPEG decoder requires complete data
|
// JPEG decoder requires complete data
|
||||||
// If we know the expected size, wait for it
|
// If we know the expected size, wait for it
|
||||||
if (this->expected_size_ > 0 && size < this->expected_size_) {
|
if (this->expected_size_ > 0 && size < this->expected_size_) {
|
||||||
@@ -105,22 +138,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
|||||||
// If size unknown, try to decode and see if it's valid
|
// If size unknown, try to decode and see if it's valid
|
||||||
// The JPEGDEC library will fail gracefully if data is incomplete
|
// The JPEGDEC library will fail gracefully if data is incomplete
|
||||||
|
|
||||||
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
|
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
|
||||||
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
|
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
|
||||||
return DECODE_ERROR_INVALID_TYPE;
|
return DECODE_ERROR_INVALID_TYPE;
|
||||||
}
|
}
|
||||||
const int err = this->start_();
|
const int err = this->start_();
|
||||||
if (err != 0)
|
if (err != 0)
|
||||||
return err;
|
return err;
|
||||||
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
|
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
|
||||||
return this->fail_();
|
return this->fail_();
|
||||||
this->decoded_bytes_ = size;
|
this->decoded_bytes_ = size;
|
||||||
this->jpeg_.close();
|
this->jpeg_->close();
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
int JpegDecoder::start_() {
|
int JpegDecoder::start_() {
|
||||||
auto jpeg_type = this->jpeg_.getJPEGType();
|
auto jpeg_type = this->jpeg_->getJPEGType();
|
||||||
if (jpeg_type == JPEG_MODE_INVALID) {
|
if (jpeg_type == JPEG_MODE_INVALID) {
|
||||||
ESP_LOGE(TAG, "Unsupported JPEG image");
|
ESP_LOGE(TAG, "Unsupported JPEG image");
|
||||||
return DECODE_ERROR_INVALID_TYPE;
|
return DECODE_ERROR_INVALID_TYPE;
|
||||||
@@ -128,11 +161,12 @@ int JpegDecoder::start_() {
|
|||||||
ESP_LOGE(TAG, "Progressive JPEG images not supported");
|
ESP_LOGE(TAG, "Progressive JPEG images not supported");
|
||||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||||
}
|
}
|
||||||
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
|
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
|
||||||
|
this->jpeg_->getBpp());
|
||||||
|
|
||||||
this->jpeg_.setUserPointer(this);
|
this->jpeg_->setUserPointer(this);
|
||||||
const int width = this->jpeg_.getWidth();
|
const int width = this->jpeg_->getWidth();
|
||||||
const int height = this->jpeg_.getHeight();
|
const int height = this->jpeg_->getHeight();
|
||||||
// The tables for drawing are only built once the decoded size is known, below
|
// The tables for drawing are only built once the decoded size is known, below
|
||||||
if (!this->resize_image_(width, height)) {
|
if (!this->resize_image_(width, height)) {
|
||||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||||
@@ -155,17 +189,17 @@ int JpegDecoder::start_() {
|
|||||||
if (this->direct_rgb565_) {
|
if (this->direct_rgb565_) {
|
||||||
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
|
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
|
||||||
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
|
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
|
||||||
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
|
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
|
||||||
} else {
|
} else {
|
||||||
this->jpeg_.setPixelType(RGB8888);
|
this->jpeg_->setPixelType(RGB8888);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int JpegDecoder::fail_() {
|
int JpegDecoder::fail_() {
|
||||||
const bool read_failed = this->source_ != nullptr && this->source_failed_;
|
const bool read_failed = this->source_ != nullptr && this->source_failed_;
|
||||||
const int error = this->jpeg_.getLastError();
|
const int error = this->jpeg_->getLastError();
|
||||||
this->jpeg_.close();
|
this->jpeg_->close();
|
||||||
this->source_ = nullptr;
|
this->source_ = nullptr;
|
||||||
if (read_failed) {
|
if (read_failed) {
|
||||||
ESP_LOGE(TAG, "Reading the image failed");
|
ESP_LOGE(TAG, "Reading the image failed");
|
||||||
@@ -189,7 +223,7 @@ int JpegDecoder::fail_() {
|
|||||||
void JpegDecoder::reset() {
|
void JpegDecoder::reset() {
|
||||||
// A decode from a source that was given up part way still has the image open
|
// A decode from a source that was given up part way still has the image open
|
||||||
if (this->source_ != nullptr) {
|
if (this->source_ != nullptr) {
|
||||||
this->jpeg_.close();
|
this->jpeg_->close();
|
||||||
this->source_ = nullptr;
|
this->source_ = nullptr;
|
||||||
}
|
}
|
||||||
ImageDecoder::reset();
|
ImageDecoder::reset();
|
||||||
@@ -218,6 +252,10 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int JpegDecoder::begin_source(DecodeSource *source) {
|
int JpegDecoder::begin_source(DecodeSource *source) {
|
||||||
|
if (this->jpeg_ == nullptr) {
|
||||||
|
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
|
||||||
|
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
|
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
|
||||||
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
|
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
|
||||||
return DECODE_ERROR_INVALID_TYPE;
|
return DECODE_ERROR_INVALID_TYPE;
|
||||||
@@ -228,19 +266,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
|||||||
}
|
}
|
||||||
this->source_ = source;
|
this->source_ = source;
|
||||||
this->source_failed_ = false;
|
this->source_failed_ = false;
|
||||||
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
|
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
|
||||||
// The caller may recover, for example by reading the whole image first, so it reports the failure
|
// The caller may recover, for example by reading the whole image first, so it reports the failure
|
||||||
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
|
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
|
||||||
this->source_ = nullptr;
|
this->source_ = nullptr;
|
||||||
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
|
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
|
||||||
}
|
}
|
||||||
const int err = this->start_();
|
const int err = this->start_();
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
this->jpeg_.close();
|
this->jpeg_->close();
|
||||||
this->source_ = nullptr;
|
this->source_ = nullptr;
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
|
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
|
||||||
return this->fail_();
|
return this->fail_();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -248,7 +286,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
|||||||
int HOT JpegDecoder::decode_step() {
|
int HOT JpegDecoder::decode_step() {
|
||||||
if (this->source_ == nullptr)
|
if (this->source_ == nullptr)
|
||||||
return DECODE_ERROR_INVALID_TYPE;
|
return DECODE_ERROR_INVALID_TYPE;
|
||||||
const int result = this->jpeg_.decodeRows(1);
|
const int result = this->jpeg_->decodeRows(1);
|
||||||
// JPEGDEC may decode on with the missing data, so check the read before its result
|
// JPEGDEC may decode on with the missing data, so check the read before its result
|
||||||
if (this->source_failed_)
|
if (this->source_failed_)
|
||||||
return this->fail_();
|
return this->fail_();
|
||||||
@@ -259,7 +297,7 @@ int HOT JpegDecoder::decode_step() {
|
|||||||
if (result == 0)
|
if (result == 0)
|
||||||
return this->fail_();
|
return this->fail_();
|
||||||
this->decoded_bytes_ = this->expected_size_;
|
this->decoded_bytes_ = this->expected_size_;
|
||||||
this->jpeg_.close();
|
this->jpeg_->close();
|
||||||
this->source_ = nullptr;
|
this->source_ = nullptr;
|
||||||
return DECODE_STEP_DONE;
|
return DECODE_STEP_DONE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ class JpegDecoder : public ImageDecoder {
|
|||||||
*
|
*
|
||||||
* @param image The RuntimeImage to decode the stream into.
|
* @param image The RuntimeImage to decode the stream into.
|
||||||
*/
|
*/
|
||||||
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
|
JpegDecoder(RuntimeImage *image);
|
||||||
~JpegDecoder() override {}
|
~JpegDecoder() override;
|
||||||
|
|
||||||
|
int prepare(size_t expected_size) override;
|
||||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||||
void reset() override;
|
void reset() override;
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ class JpegDecoder : public ImageDecoder {
|
|||||||
static int32_t source_seek(JPEGFILE *file, int32_t position);
|
static int32_t source_seek(JPEGFILE *file, int32_t position);
|
||||||
static void source_close(void *handle) {}
|
static void source_close(void *handle) {}
|
||||||
|
|
||||||
JPEGDEC jpeg_{};
|
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
|
||||||
|
JPEGDEC *jpeg_{nullptr};
|
||||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||||
bool source_failed_{false};
|
bool source_failed_{false};
|
||||||
|
|||||||
@@ -9,16 +9,33 @@ ESPHOME_LOG_TAG(TAG, "rx8111");
|
|||||||
|
|
||||||
// Indexed by the TSEL value stored in timer_clock_
|
// Indexed by the TSEL value stored in timer_clock_
|
||||||
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
|
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
|
||||||
|
PROGMEM_STRING_TABLE(FoutNames, "32768 Hz", "1024 Hz", "1 Hz", "off");
|
||||||
|
PROGMEM_STRING_TABLE(EvinPullNames, "none", "pullup_500k", "pullup_1m", "pullup_10m", "pulldown_500k");
|
||||||
|
PROGMEM_STRING_TABLE(BootFailureNames, "RX8111 battery configuration failed", "RX8111 FOUT configuration failed",
|
||||||
|
"RX8111 EVIN configuration failed", ESP_LOG_MSG_COMM_FAIL, ESP_LOG_MSG_COMM_FAIL);
|
||||||
|
|
||||||
static constexpr uint8_t RX8111_REG_SEC = 0x10;
|
static constexpr uint8_t RX8111_REG_SEC = 0x10;
|
||||||
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
|
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
|
||||||
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
|
|
||||||
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
|
|
||||||
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
|
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
|
||||||
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
|
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
|
||||||
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
|
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
|
||||||
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
|
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
|
||||||
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
|
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
|
||||||
|
static constexpr uint8_t RX8111_REG_TIMER_CONTROL = 0x2D;
|
||||||
|
static constexpr uint8_t RX8111_REG_POWER_SWITCH = 0x32;
|
||||||
|
static constexpr uint8_t RX8111_REG_TIMESTAMP_CONTROL = 0x34;
|
||||||
|
static constexpr uint8_t RX8111_TIMESTAMP_EISEL = 0x04;
|
||||||
|
static constexpr uint8_t RX8111_REG_EVIN = 0x2B;
|
||||||
|
static constexpr uint8_t RX8111_EXTENSION_ETS = 0x04;
|
||||||
|
static constexpr uint8_t RX8111_CONTROL_EIE = 0x04;
|
||||||
|
static constexpr uint8_t RX8111_FLAG_EVF = 0x04;
|
||||||
|
static constexpr uint8_t RX8111_POWER_CHGEN = 0x80;
|
||||||
|
static constexpr uint8_t RX8111_POWER_INIEN = 0x40;
|
||||||
|
static constexpr uint8_t RX8111_POWER_SWSEL = 0x0C;
|
||||||
|
static constexpr uint8_t RX8111_POWER_VDD_ONLY = 0x08;
|
||||||
|
static constexpr uint8_t RX8111_FOUT_FSEL = 0xC0;
|
||||||
|
static constexpr uint8_t RX8111_TIMER_TMPIN = 0x02;
|
||||||
|
static constexpr uint8_t RX8111_TIMER_MODE = 0x0F; // TBKON, TBKE, TMPIN, TSTP
|
||||||
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
|
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
|
||||||
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
|
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
|
||||||
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
|
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
|
||||||
@@ -36,17 +53,121 @@ static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
|
|||||||
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
|
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
|
||||||
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
|
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
|
||||||
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
|
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
|
||||||
|
static constexpr uint32_t RX8111_BOOT_BATTERY_RETRY_ID = 3;
|
||||||
|
static constexpr uint32_t RX8111_BOOT_FOUT_RETRY_ID = 4;
|
||||||
|
static constexpr uint32_t RX8111_BOOT_EVENT_RETRY_ID = 5;
|
||||||
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
|
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
void RX8111Component::setup() {
|
void RX8111Component::setup() {
|
||||||
|
if (this->battery_configured_)
|
||||||
|
this->configure_boot_battery_();
|
||||||
|
if (this->fout_configured_)
|
||||||
|
this->configure_boot_fout_();
|
||||||
|
if (this->event_configured_)
|
||||||
|
this->configure_boot_event_();
|
||||||
this->configure_boot_alarm_();
|
this->configure_boot_alarm_();
|
||||||
this->configure_boot_timer_();
|
this->configure_boot_timer_();
|
||||||
this->read_time();
|
this->read_time();
|
||||||
if (this->alarm_enabled_ || this->watching_()) {
|
if (this->watching_()) {
|
||||||
this->start_event_polling_();
|
this->start_event_polling_();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RX8111Component::configure_boot_event_() {
|
||||||
|
const bool success = this->configure_event_();
|
||||||
|
if (success) {
|
||||||
|
this->event_boot_pending_ = false;
|
||||||
|
if (this->watching_())
|
||||||
|
this->start_event_polling_();
|
||||||
|
} else {
|
||||||
|
this->event_boot_pending_ = true;
|
||||||
|
}
|
||||||
|
this->boot_config_result_(BOOT_EVENT, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RX8111Component::configure_event_() {
|
||||||
|
// An explicit disable affects only /INT; leave retained capture settings untouched.
|
||||||
|
if (!this->event_enabled_)
|
||||||
|
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, 0);
|
||||||
|
// ETS gates EVIN detection, not just timestamp publication. Preserve all other
|
||||||
|
// extension bits, including FOUT, alarm and timer settings, and retained records.
|
||||||
|
return this->update_register(RX8111_REG_EVIN, 0xFC, this->evin_settings_ & 0xFC) &&
|
||||||
|
this->update_register(RX8111_REG_TIMESTAMP_CONTROL, RX8111_TIMESTAMP_EISEL, 0) &&
|
||||||
|
this->update_register(RX8111_REG_EXTENSION, RX8111_EXTENSION_ETS, RX8111_EXTENSION_ETS) &&
|
||||||
|
this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, RX8111_CONTROL_EIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RX8111Component::clear_event_flag() {
|
||||||
|
// FLAG is write-zero-to-clear; ones preserve other events, even if they latch during the write.
|
||||||
|
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_EVF))) {
|
||||||
|
this->set_warning_(WARN_EVENT_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this->event_flag_seen_ = false;
|
||||||
|
this->clear_warning_(WARN_EVENT_FLAG_CLEAR);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RX8111Component::configure_boot_battery_() {
|
||||||
|
// Own SWSEL as well as INIEN/CHGEN: 10 selects VDD when automatic switching is disabled
|
||||||
|
// and is legal when enabled (unlike 11). Preserve SMPT and reserved bits.
|
||||||
|
const uint8_t value = RX8111_POWER_VDD_ONLY | (this->battery_backup_ ? RX8111_POWER_INIEN : 0) |
|
||||||
|
(this->battery_backup_ && this->battery_charging_ ? RX8111_POWER_CHGEN : 0);
|
||||||
|
this->boot_config_result_(BOOT_BATTERY,
|
||||||
|
this->update_register(RX8111_REG_POWER_SWITCH,
|
||||||
|
RX8111_POWER_INIEN | RX8111_POWER_CHGEN | RX8111_POWER_SWSEL, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RX8111Component::configure_boot_fout_() {
|
||||||
|
// TMPIN routes timer interrupts to FOUT instead of /INT; do not silently change that routing.
|
||||||
|
uint8_t timer_control;
|
||||||
|
bool success = this->read_byte(RX8111_REG_TIMER_CONTROL, &timer_control);
|
||||||
|
if (success && this->fout_frequency_ != 3 && (timer_control & RX8111_TIMER_TMPIN)) {
|
||||||
|
ESP_LOGW(TAG, "FOUT is routed to the wake-up timer (TMPIN); cannot configure clock output");
|
||||||
|
success = false;
|
||||||
|
} else if (success) {
|
||||||
|
success =
|
||||||
|
this->update_register(RX8111_REG_EXTENSION, RX8111_FOUT_FSEL, static_cast<uint8_t>(this->fout_frequency_ << 6));
|
||||||
|
}
|
||||||
|
this->boot_config_result_(BOOT_FOUT, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timer start clears TMPIN, which may be what failed FOUT; give it a fresh set of attempts.
|
||||||
|
void RX8111Component::retry_fout_after_timer_start_() {
|
||||||
|
if (!this->fout_configured_ || (this->warnings_ & WARN_FOUT) == 0)
|
||||||
|
return;
|
||||||
|
this->cancel_timeout(RX8111_BOOT_FOUT_RETRY_ID);
|
||||||
|
this->boot_attempts_[BOOT_FOUT] = 0;
|
||||||
|
this->configure_boot_fout_();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RX8111Component::boot_config_result_(BootFeature feature, bool success) {
|
||||||
|
struct BootConfig {
|
||||||
|
uint32_t retry_id;
|
||||||
|
void (RX8111Component::*configure)();
|
||||||
|
uint16_t warning;
|
||||||
|
};
|
||||||
|
static constexpr BootConfig CONFIGS[BOOT_FEATURE_COUNT] PROGMEM = {
|
||||||
|
{RX8111_BOOT_BATTERY_RETRY_ID, &RX8111Component::configure_boot_battery_, WARN_BATTERY},
|
||||||
|
{RX8111_BOOT_FOUT_RETRY_ID, &RX8111Component::configure_boot_fout_, WARN_FOUT},
|
||||||
|
{RX8111_BOOT_EVENT_RETRY_ID, &RX8111Component::configure_boot_event_, WARN_EVENT},
|
||||||
|
{RX8111_BOOT_ALARM_RETRY_ID, &RX8111Component::configure_boot_alarm_, WARN_ALARM},
|
||||||
|
{RX8111_BOOT_TIMER_RETRY_ID, &RX8111Component::configure_boot_timer_, WARN_TIMER},
|
||||||
|
};
|
||||||
|
BootConfig config;
|
||||||
|
progmem_memcpy(&config, &CONFIGS[feature], sizeof(config));
|
||||||
|
const auto attempts = ++this->boot_attempts_[feature];
|
||||||
|
if (success) {
|
||||||
|
this->clear_warning_(config.warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->set_warning_(config.warning, BootFailureNames::get_log_str(feature, BootFailureNames::LAST_INDEX));
|
||||||
|
if (attempts < RX8111_BOOT_MAX_ATTEMPTS) {
|
||||||
|
this->set_timeout(config.retry_id, this->boot_retry_delay_ms_,
|
||||||
|
[this, configure = config.configure]() { (this->*configure)(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void RX8111Component::configure_boot_alarm_() {
|
void RX8111Component::configure_boot_alarm_() {
|
||||||
bool success = true;
|
bool success = true;
|
||||||
if (this->alarm_enabled_) {
|
if (this->alarm_enabled_) {
|
||||||
@@ -74,7 +195,7 @@ void RX8111Component::configure_boot_alarm_() {
|
|||||||
} else if (this->alarm_explicitly_configured_) {
|
} else if (this->alarm_explicitly_configured_) {
|
||||||
success = this->set_alarm_interrupt_(false);
|
success = this->set_alarm_interrupt_(false);
|
||||||
}
|
}
|
||||||
this->boot_config_result_(true, success);
|
this->boot_config_result_(BOOT_ALARM, success);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RX8111Component::configure_boot_timer_() {
|
void RX8111Component::configure_boot_timer_() {
|
||||||
@@ -92,29 +213,10 @@ void RX8111Component::configure_boot_timer_() {
|
|||||||
this->timer_retained_event_pending_ = true;
|
this->timer_retained_event_pending_ = true;
|
||||||
this->start_event_polling_();
|
this->start_event_polling_();
|
||||||
}
|
}
|
||||||
|
if (success && start_timer)
|
||||||
|
this->retry_fout_after_timer_start_();
|
||||||
}
|
}
|
||||||
this->boot_config_result_(false, success);
|
this->boot_config_result_(BOOT_TIMER, success);
|
||||||
}
|
|
||||||
|
|
||||||
void RX8111Component::boot_config_result_(bool alarm, bool success) {
|
|
||||||
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
|
|
||||||
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
|
|
||||||
++attempts;
|
|
||||||
if (success) {
|
|
||||||
this->clear_warning_(warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
|
||||||
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
|
|
||||||
return;
|
|
||||||
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
|
|
||||||
[this, alarm]() {
|
|
||||||
if (alarm) {
|
|
||||||
this->configure_boot_alarm_();
|
|
||||||
} else {
|
|
||||||
this->configure_boot_timer_();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A runtime action supersedes pending boot programming of the same feature
|
// A runtime action supersedes pending boot programming of the same feature
|
||||||
@@ -144,12 +246,12 @@ void RX8111Component::update() {
|
|||||||
this->check_event_flags_();
|
this->check_event_flags_();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
|
void RX8111Component::set_warning_(uint16_t bit, const LogString *message) {
|
||||||
this->warnings_ |= bit;
|
this->warnings_ |= bit;
|
||||||
this->status_set_warning(message);
|
this->status_set_warning(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RX8111Component::clear_warning_(uint8_t bit) {
|
void RX8111Component::clear_warning_(uint16_t bit) {
|
||||||
if ((this->warnings_ & bit) == 0)
|
if ((this->warnings_ & bit) == 0)
|
||||||
return;
|
return;
|
||||||
this->warnings_ &= ~bit;
|
this->warnings_ &= ~bit;
|
||||||
@@ -160,6 +262,23 @@ void RX8111Component::clear_warning_(uint8_t bit) {
|
|||||||
void RX8111Component::dump_config() {
|
void RX8111Component::dump_config() {
|
||||||
ESP_LOGCONFIG(TAG, "RX8111:");
|
ESP_LOGCONFIG(TAG, "RX8111:");
|
||||||
LOG_I2C_DEVICE(this);
|
LOG_I2C_DEVICE(this);
|
||||||
|
if (this->battery_configured_) {
|
||||||
|
ESP_LOGCONFIG(TAG, " Battery backup: %s, charging: %s", ONOFF(this->battery_backup_),
|
||||||
|
ONOFF(this->battery_charging_));
|
||||||
|
}
|
||||||
|
if (this->fout_configured_) {
|
||||||
|
ESP_LOGCONFIG(TAG, " FOUT: %s", LOG_STR_ARG(FoutNames::get_log_str(this->fout_frequency_, FoutNames::LAST_INDEX)));
|
||||||
|
}
|
||||||
|
if (this->event_configured_) {
|
||||||
|
ESP_LOGCONFIG(TAG, " EVIN interrupt: %s", ONOFF(this->event_enabled_));
|
||||||
|
if (this->event_enabled_) {
|
||||||
|
ESP_LOGCONFIG(
|
||||||
|
TAG, " EVIN: %s, filter: %u, pull: %s",
|
||||||
|
(this->evin_settings_ & 0x80) ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"),
|
||||||
|
(this->evin_settings_ >> 5) & 3,
|
||||||
|
LOG_STR_ARG(EvinPullNames::get_log_str((this->evin_settings_ >> 2) & 7, EvinPullNames::LAST_INDEX)));
|
||||||
|
}
|
||||||
|
}
|
||||||
ESP_LOGCONFIG(TAG,
|
ESP_LOGCONFIG(TAG,
|
||||||
" Alarm interrupt: %s\n"
|
" Alarm interrupt: %s\n"
|
||||||
" Wake-up timer: %s",
|
" Wake-up timer: %s",
|
||||||
@@ -181,12 +300,20 @@ void RX8111Component::read_time() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
||||||
uint8_t flags;
|
if (this->time_write_invalid_) {
|
||||||
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
|
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t regs[2]; // Adjacent FLAG and CONTROL registers.
|
||||||
|
if (!this->read_bytes(RX8111_REG_FLAG, regs, sizeof(regs))) {
|
||||||
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
|
if (regs[1] & RX8111_CTRL_STOP) {
|
||||||
|
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC counter stopped; not synchronizing"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (regs[0] & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
|
||||||
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
|
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
|
||||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
|
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
|
||||||
return false;
|
return false;
|
||||||
@@ -199,12 +326,12 @@ bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
|
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
|
||||||
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
static constexpr uint8_t CALENDAR_MASKS[7] PROGMEM = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
||||||
bool valid = true;
|
bool valid = true;
|
||||||
for (size_t i = 0; i < sizeof(buf); i++) {
|
for (size_t i = 0; i < sizeof(buf); i++) {
|
||||||
if (i == 3)
|
if (i == 3)
|
||||||
continue; // Weekday uses one-hot encoding, not BCD.
|
continue; // Weekday uses one-hot encoding, not BCD.
|
||||||
buf[i] &= CALENDAR_MASKS[i];
|
buf[i] &= progmem_read_byte(&CALENDAR_MASKS[i]);
|
||||||
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
|
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
|
||||||
}
|
}
|
||||||
const uint8_t onehot = buf[3];
|
const uint8_t onehot = buf[3];
|
||||||
@@ -236,23 +363,31 @@ void RX8111Component::write_time() {
|
|||||||
|
|
||||||
uint8_t buf[7];
|
uint8_t buf[7];
|
||||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
|
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
|
||||||
const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
this->time_write_invalid_ = true;
|
||||||
// Always try to restart the counter, even after a failed calendar write.
|
const bool calendar_written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||||
const bool restarted = this->set_stop_bit_(false);
|
if (!this->finish_time_write_(calendar_written))
|
||||||
if (!written || !restarted) {
|
|
||||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
|
|
||||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this->clear_warning_(WARN_WRITE_TIME);
|
|
||||||
this->clear_warning_(WARN_CALENDAR);
|
|
||||||
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
|
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
|
||||||
now.minute, now.second);
|
now.minute, now.second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RX8111Component::finish_time_write_(bool calendar_written) {
|
||||||
|
// STOP is the battery-backed invalidity marker, including across host resets.
|
||||||
|
// Never release a partial or delayed snapshot; only a fresh trusted write can restart it.
|
||||||
|
if (!calendar_written) {
|
||||||
|
this->set_warning_(WARN_WRITE_TIME, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS) || !this->set_stop_bit_(false)) {
|
||||||
|
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this->time_write_invalid_ = false;
|
||||||
|
this->clear_warning_(WARN_WRITE_TIME);
|
||||||
|
this->clear_warning_(WARN_CALENDAR);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool RX8111Component::set_stop_bit_(bool stop) {
|
bool RX8111Component::set_stop_bit_(bool stop) {
|
||||||
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
|
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
|
||||||
}
|
}
|
||||||
@@ -383,6 +518,11 @@ bool RX8111Component::configure_timer_(bool clear_stale_flag) {
|
|||||||
if (!this->disable_timer_(extension, control))
|
if (!this->disable_timer_(extension, control))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
// Explicit starts own the timer mode: count in both power modes, do not pause,
|
||||||
|
// and route interrupt pulses to /INT rather than FOUT. Leave reserved bits alone.
|
||||||
|
if (!this->update_register(RX8111_REG_TIMER_CONTROL, RX8111_TIMER_MODE, 0))
|
||||||
|
return false;
|
||||||
|
|
||||||
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
|
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
|
||||||
static_cast<uint8_t>(this->timer_count_ >> 16)};
|
static_cast<uint8_t>(this->timer_count_ >> 16)};
|
||||||
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
|
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
|
||||||
@@ -414,6 +554,7 @@ bool RX8111Component::start_timer() {
|
|||||||
this->timer_enabled_ = true;
|
this->timer_enabled_ = true;
|
||||||
this->timer_retained_event_pending_ = false;
|
this->timer_retained_event_pending_ = false;
|
||||||
this->start_event_polling_();
|
this->start_event_polling_();
|
||||||
|
this->retry_fout_after_timer_start_();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +586,12 @@ bool RX8111Component::poll_event_flags_() {
|
|||||||
if (!this->read_byte(RX8111_REG_FLAG, &flags))
|
if (!this->read_byte(RX8111_REG_FLAG, &flags))
|
||||||
return false;
|
return false;
|
||||||
this->clear_warning_(WARN_EVENT_POLL);
|
this->clear_warning_(WARN_EVENT_POLL);
|
||||||
|
// EVF is not cleared here: users may use it to hold /INT asserted across sleep.
|
||||||
|
const bool event_active = (flags & RX8111_FLAG_EVF) != 0;
|
||||||
|
const bool event_rising =
|
||||||
|
event_active && !this->event_flag_seen_ && this->event_enabled_ && !this->event_boot_pending_;
|
||||||
|
if (this->event_enabled_ && !this->event_boot_pending_)
|
||||||
|
this->event_flag_seen_ = event_active;
|
||||||
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
|
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
|
||||||
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
|
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
|
||||||
// alarm_armed_ is only ever set while the alarm is enabled
|
// alarm_armed_ is only ever set while the alarm is enabled
|
||||||
@@ -466,6 +613,8 @@ bool RX8111Component::poll_event_flags_() {
|
|||||||
// An alarm callback may stop the timer before its callback is delivered.
|
// An alarm callback may stop the timer before its callback is delivered.
|
||||||
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
|
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
|
||||||
this->timer_callbacks_.call();
|
this->timer_callbacks_.call();
|
||||||
|
if (event_rising && this->event_flag_seen_)
|
||||||
|
this->event_callbacks_.call();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
|||||||
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
|
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
|
||||||
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
|
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
|
||||||
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
|
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
|
||||||
|
void set_battery_backup(bool enabled, bool charging) {
|
||||||
|
this->battery_configured_ = true;
|
||||||
|
this->battery_backup_ = enabled;
|
||||||
|
this->battery_charging_ = charging;
|
||||||
|
}
|
||||||
|
void set_fout_frequency(uint8_t frequency) {
|
||||||
|
this->fout_configured_ = true;
|
||||||
|
this->fout_frequency_ = frequency;
|
||||||
|
}
|
||||||
|
void set_event_config(uint8_t settings) {
|
||||||
|
this->event_configured_ = true;
|
||||||
|
this->event_enabled_ = true;
|
||||||
|
this->evin_settings_ = settings;
|
||||||
|
}
|
||||||
|
void set_event_disabled() {
|
||||||
|
this->event_configured_ = true;
|
||||||
|
this->event_enabled_ = false;
|
||||||
|
}
|
||||||
|
template<typename F> void add_on_event_callback(F &&callback) {
|
||||||
|
this->event_callbacks_.add(std::forward<F>(callback));
|
||||||
|
}
|
||||||
|
bool clear_event_flag();
|
||||||
template<typename F> void add_on_alarm_callback(F &&callback) {
|
template<typename F> void add_on_alarm_callback(F &&callback) {
|
||||||
this->alarm_callbacks_.add(std::forward<F>(callback));
|
this->alarm_callbacks_.add(std::forward<F>(callback));
|
||||||
}
|
}
|
||||||
@@ -51,7 +73,7 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
|||||||
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
|
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
|
||||||
|
|
||||||
// One bit per fault source, so a recovery in one path cannot hide another path's fault
|
// One bit per fault source, so a recovery in one path cannot hide another path's fault
|
||||||
enum Warning : uint8_t {
|
enum Warning : uint16_t {
|
||||||
WARN_CALENDAR = 1 << 0,
|
WARN_CALENDAR = 1 << 0,
|
||||||
WARN_EVENT_POLL = 1 << 1,
|
WARN_EVENT_POLL = 1 << 1,
|
||||||
WARN_ALARM = 1 << 2,
|
WARN_ALARM = 1 << 2,
|
||||||
@@ -59,13 +81,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
|||||||
WARN_WRITE_TIME = 1 << 4,
|
WARN_WRITE_TIME = 1 << 4,
|
||||||
WARN_ALARM_FLAG_CLEAR = 1 << 5,
|
WARN_ALARM_FLAG_CLEAR = 1 << 5,
|
||||||
WARN_TIMER_FLAG_CLEAR = 1 << 6,
|
WARN_TIMER_FLAG_CLEAR = 1 << 6,
|
||||||
|
WARN_BATTERY = 1 << 7,
|
||||||
|
WARN_FOUT = 1 << 8,
|
||||||
|
WARN_EVENT = 1 << 9,
|
||||||
|
WARN_EVENT_FLAG_CLEAR = 1 << 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
bool set_stop_bit_(bool stop);
|
bool set_stop_bit_(bool stop);
|
||||||
|
bool finish_time_write_(bool calendar_written);
|
||||||
bool read_calendar_(ESPTime &rtc_time);
|
bool read_calendar_(ESPTime &rtc_time);
|
||||||
void set_warning_(uint8_t bit, const LogString *message);
|
void set_warning_(uint16_t bit, const LogString *message);
|
||||||
void clear_warning_(uint8_t bit);
|
void clear_warning_(uint16_t bit);
|
||||||
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
|
void configure_boot_battery_();
|
||||||
|
void configure_boot_fout_();
|
||||||
|
void retry_fout_after_timer_start_();
|
||||||
|
enum BootFeature : uint8_t { BOOT_BATTERY, BOOT_FOUT, BOOT_EVENT, BOOT_ALARM, BOOT_TIMER, BOOT_FEATURE_COUNT };
|
||||||
|
void boot_config_result_(BootFeature feature, bool success);
|
||||||
|
bool watching_() const {
|
||||||
|
return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_ ||
|
||||||
|
(this->event_enabled_ && !this->event_boot_pending_ && !this->event_callbacks_.empty());
|
||||||
|
}
|
||||||
|
void configure_boot_event_();
|
||||||
|
bool configure_event_();
|
||||||
void check_event_flags_();
|
void check_event_flags_();
|
||||||
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
|
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
|
||||||
bool set_alarm_interrupt_(bool enabled);
|
bool set_alarm_interrupt_(bool enabled);
|
||||||
@@ -75,33 +112,43 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
|||||||
bool poll_event_flags_();
|
bool poll_event_flags_();
|
||||||
void configure_boot_alarm_();
|
void configure_boot_alarm_();
|
||||||
void configure_boot_timer_();
|
void configure_boot_timer_();
|
||||||
void boot_config_result_(bool alarm, bool success);
|
|
||||||
void cancel_boot_retry_(bool alarm);
|
void cancel_boot_retry_(bool alarm);
|
||||||
|
|
||||||
LazyCallbackManager<void()> alarm_callbacks_;
|
LazyCallbackManager<void()> alarm_callbacks_;
|
||||||
LazyCallbackManager<void()> timer_callbacks_;
|
LazyCallbackManager<void()> timer_callbacks_;
|
||||||
|
LazyCallbackManager<void()> event_callbacks_;
|
||||||
uint32_t timer_count_{0};
|
uint32_t timer_count_{0};
|
||||||
uint32_t event_poll_interval_{1000};
|
uint32_t event_poll_interval_{1000};
|
||||||
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
|
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
|
||||||
uint8_t warnings_{0};
|
uint16_t warnings_{0};
|
||||||
|
uint8_t boot_attempts_[BOOT_FEATURE_COUNT]{};
|
||||||
|
uint8_t evin_settings_{0};
|
||||||
|
uint8_t fout_frequency_{3};
|
||||||
uint8_t timer_clock_{2}; // 1 Hz
|
uint8_t timer_clock_{2}; // 1 Hz
|
||||||
uint8_t boot_alarm_attempts_{0};
|
|
||||||
uint8_t boot_timer_attempts_{0};
|
|
||||||
uint8_t alarm_second_{ALARM_FIELD_UNSET};
|
uint8_t alarm_second_{ALARM_FIELD_UNSET};
|
||||||
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
|
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
|
||||||
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
|
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
|
||||||
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
|
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
|
||||||
uint8_t alarm_day_{ALARM_FIELD_UNSET};
|
uint8_t alarm_day_{ALARM_FIELD_UNSET};
|
||||||
bool timer_enabled_{false};
|
bool time_write_invalid_ : 1 {false};
|
||||||
bool timer_configured_{false};
|
bool battery_configured_ : 1 {false};
|
||||||
bool timer_flag_seen_{false};
|
bool battery_backup_ : 1 {false};
|
||||||
bool timer_retained_event_pending_{false};
|
bool battery_charging_ : 1 {false};
|
||||||
bool event_polling_started_{false};
|
bool fout_configured_ : 1 {false};
|
||||||
bool boot_alarm_event_seen_{false};
|
bool event_configured_ : 1 {false};
|
||||||
bool alarm_enabled_{false};
|
bool event_enabled_ : 1 {false};
|
||||||
bool alarm_explicitly_configured_{false};
|
bool event_flag_seen_ : 1 {false};
|
||||||
bool alarm_flag_seen_{false};
|
bool event_boot_pending_ : 1 {false};
|
||||||
bool alarm_armed_{false};
|
bool timer_enabled_ : 1 {false};
|
||||||
|
bool timer_configured_ : 1 {false};
|
||||||
|
bool timer_flag_seen_ : 1 {false};
|
||||||
|
bool timer_retained_event_pending_ : 1 {false};
|
||||||
|
bool event_polling_started_ : 1 {false};
|
||||||
|
bool boot_alarm_event_seen_ : 1 {false};
|
||||||
|
bool alarm_enabled_ : 1 {false};
|
||||||
|
bool alarm_explicitly_configured_ : 1 {false};
|
||||||
|
bool alarm_flag_seen_ : 1 {false};
|
||||||
|
bool alarm_armed_ : 1 {false};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace esphome::rx8111
|
} // namespace esphome::rx8111
|
||||||
@@ -10,6 +10,7 @@ from esphome.const import (
|
|||||||
CONF_HOUR,
|
CONF_HOUR,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
CONF_MINUTE,
|
CONF_MINUTE,
|
||||||
|
CONF_ON_EVENT,
|
||||||
CONF_SECOND,
|
CONF_SECOND,
|
||||||
)
|
)
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
@@ -20,6 +21,14 @@ DEPENDENCIES = ["i2c"]
|
|||||||
rx8111_ns = cg.esphome_ns.namespace("rx8111")
|
rx8111_ns = cg.esphome_ns.namespace("rx8111")
|
||||||
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
|
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
|
||||||
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
|
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
|
||||||
|
FOUT_FREQUENCIES = {"32768hz": 0, "1024hz": 1, "1hz": 2, "off": 3}
|
||||||
|
EVIN_PULLS = {
|
||||||
|
"none": 0,
|
||||||
|
"pullup_500k": 0x04,
|
||||||
|
"pullup_1m": 0x08,
|
||||||
|
"pullup_10m": 0x0C,
|
||||||
|
"pulldown_500k": 0x10,
|
||||||
|
}
|
||||||
# set_alarm() ignores a field set to this value
|
# set_alarm() ignores a field set to this value
|
||||||
ALARM_FIELD_UNSET = 0xFF
|
ALARM_FIELD_UNSET = 0xFF
|
||||||
|
|
||||||
@@ -36,6 +45,13 @@ CONF_TIMER_ENABLED = "timer_enabled"
|
|||||||
CONF_ON_TIMER = "on_timer"
|
CONF_ON_TIMER = "on_timer"
|
||||||
CONF_WEEKDAY = "weekday"
|
CONF_WEEKDAY = "weekday"
|
||||||
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
|
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
|
||||||
|
CONF_BATTERY_BACKUP = "battery_backup"
|
||||||
|
CONF_BATTERY_CHARGING = "battery_charging"
|
||||||
|
CONF_FOUT_FREQUENCY = "fout_frequency"
|
||||||
|
CONF_EVENT_ENABLED = "event_enabled"
|
||||||
|
CONF_EVENT_LEVEL = "event_level"
|
||||||
|
CONF_EVIN_FILTER = "evin_filter"
|
||||||
|
CONF_EVIN_PULL = "evin_pull"
|
||||||
|
|
||||||
_ALARM_FIELDS = (
|
_ALARM_FIELDS = (
|
||||||
CONF_ALARM_SECOND,
|
CONF_ALARM_SECOND,
|
||||||
@@ -47,6 +63,7 @@ _ALARM_FIELDS = (
|
|||||||
_CALLBACK_AUTOMATIONS = (
|
_CALLBACK_AUTOMATIONS = (
|
||||||
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
|
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
|
||||||
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
|
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
|
||||||
|
automation.CallbackAutomation(CONF_ON_EVENT, "add_on_event_callback"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +79,16 @@ def _validate_alarm_config(config: ConfigType) -> ConfigType:
|
|||||||
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
|
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
|
||||||
):
|
):
|
||||||
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
|
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
|
||||||
|
if CONF_BATTERY_CHARGING in config and CONF_BATTERY_BACKUP not in config:
|
||||||
|
raise cv.Invalid("battery_charging requires an explicit battery_backup setting")
|
||||||
|
if config.get(CONF_BATTERY_CHARGING) and not config.get(CONF_BATTERY_BACKUP):
|
||||||
|
raise cv.Invalid("battery_charging: true requires battery_backup: true")
|
||||||
|
if CONF_ON_EVENT in config and not config.get(CONF_EVENT_ENABLED):
|
||||||
|
raise cv.Invalid("on_event requires event_enabled: true")
|
||||||
|
if any(
|
||||||
|
k in config for k in (CONF_EVENT_LEVEL, CONF_EVIN_FILTER, CONF_EVIN_PULL)
|
||||||
|
) and not config.get(CONF_EVENT_ENABLED):
|
||||||
|
raise cv.Invalid("EVIN settings require event_enabled: true")
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +113,16 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
TIMER_CLOCKS, lower=True
|
TIMER_CLOCKS, lower=True
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
|
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
|
||||||
|
cv.Optional(CONF_BATTERY_BACKUP): cv.boolean,
|
||||||
|
cv.Optional(CONF_BATTERY_CHARGING): cv.boolean,
|
||||||
|
cv.Optional(CONF_FOUT_FREQUENCY): cv.enum(FOUT_FREQUENCIES, lower=True),
|
||||||
|
cv.Optional(CONF_EVENT_ENABLED): cv.boolean,
|
||||||
|
cv.Optional(CONF_EVENT_LEVEL): cv.enum(
|
||||||
|
{"low": 0, "high": 0x80}, lower=True
|
||||||
|
),
|
||||||
|
cv.Optional(CONF_EVIN_FILTER): cv.int_range(min=0, max=3),
|
||||||
|
cv.Optional(CONF_EVIN_PULL): cv.enum(EVIN_PULLS, lower=True),
|
||||||
|
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
|
||||||
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
|
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
|
||||||
# Below 500 ms a scheduler interval costs more than it saves
|
# Below 500 ms a scheduler interval costs more than it saves
|
||||||
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
|
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
|
||||||
@@ -107,6 +144,7 @@ for _method in (
|
|||||||
"read_time",
|
"read_time",
|
||||||
"clear_alarm_flag",
|
"clear_alarm_flag",
|
||||||
"clear_timer_flag",
|
"clear_timer_flag",
|
||||||
|
"clear_event_flag",
|
||||||
"unset_alarm",
|
"unset_alarm",
|
||||||
"stop_timer",
|
"stop_timer",
|
||||||
"start_timer",
|
"start_timer",
|
||||||
@@ -184,6 +222,16 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
var = await i2c.new_i2c_component(config)
|
var = await i2c.new_i2c_component(config)
|
||||||
await time.register_time(var, config)
|
await time.register_time(var, config)
|
||||||
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
|
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
|
||||||
|
if (backup := config.get(CONF_BATTERY_BACKUP)) is not None:
|
||||||
|
cg.add(var.set_battery_backup(backup, config.get(CONF_BATTERY_CHARGING, False)))
|
||||||
|
cg.add_optional(var.set_fout_frequency, config.get(CONF_FOUT_FREQUENCY))
|
||||||
|
if config.get(CONF_EVENT_ENABLED):
|
||||||
|
level = config[CONF_EVENT_LEVEL].enum_value if CONF_EVENT_LEVEL in config else 0
|
||||||
|
pull = config[CONF_EVIN_PULL].enum_value if CONF_EVIN_PULL in config else 0
|
||||||
|
filter_bits = config.get(CONF_EVIN_FILTER, 0) << 5
|
||||||
|
cg.add(var.set_event_config(level | filter_bits | pull))
|
||||||
|
elif CONF_EVENT_ENABLED in config:
|
||||||
|
cg.add(var.set_event_disabled())
|
||||||
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
|
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
|
||||||
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
|
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
|
||||||
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
|
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ from esphome.core import (
|
|||||||
)
|
)
|
||||||
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
|
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
|
||||||
from esphome.core.entity_helpers import (
|
from esphome.core.entity_helpers import (
|
||||||
|
FollowEntity,
|
||||||
SubEntities,
|
SubEntities,
|
||||||
entity_duplicate_validator,
|
entity_duplicate_validator,
|
||||||
lazy_load_validator,
|
lazy_load_validator,
|
||||||
@@ -1109,6 +1110,10 @@ def sub_sensors(
|
|||||||
return SubEntities(new_sensor, config, parent)
|
return SubEntities(new_sensor, config, parent)
|
||||||
|
|
||||||
|
|
||||||
|
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
|
||||||
|
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
|
||||||
|
|
||||||
|
|
||||||
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
|
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_ID): cv.use_id(Sensor),
|
cv.Required(CONF_ID): cv.use_id(Sensor),
|
||||||
|
|||||||
@@ -2,29 +2,22 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import binary_sensor
|
from esphome.components import binary_sensor
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_SOURCE_ID
|
from esphome.const import CONF_SOURCE_ID
|
||||||
|
from esphome.core.entity_helpers import FollowEntity
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import Switch, switch_ns
|
from .. import Switch
|
||||||
|
|
||||||
CODEOWNERS = ["@ssieb"]
|
CODEOWNERS = ["@ssieb"]
|
||||||
|
|
||||||
SwitchBinarySensor = switch_ns.class_(
|
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
|
||||||
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
|
|
||||||
|
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = (
|
async def to_code(config: ConfigType) -> None:
|
||||||
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.extend(cv.COMPONENT_SCHEMA)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
|
||||||
var = await binary_sensor.new_binary_sensor(config)
|
var = await binary_sensor.new_binary_sensor(config)
|
||||||
await cg.register_component(var, config)
|
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||||
|
|
||||||
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#include "switch_binary_sensor.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::switch_ {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
|
|
||||||
|
|
||||||
void SwitchBinarySensor::setup() {
|
|
||||||
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
|
|
||||||
this->publish_state(source_->state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
|
|
||||||
|
|
||||||
} // namespace esphome::switch_
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../switch.h"
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
|
||||||
|
|
||||||
namespace esphome::switch_ {
|
|
||||||
|
|
||||||
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
|
|
||||||
public:
|
|
||||||
void set_source(Switch *source) { source_ = source; }
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
Switch *source_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::switch_
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import climate, sensor
|
from esphome.components import climate, sensor
|
||||||
@@ -27,11 +29,22 @@ from esphome.const import (
|
|||||||
CONF_TARGET_TEMPERATURE,
|
CONF_TARGET_TEMPERATURE,
|
||||||
CONF_TARGET_TEMPERATURE_HIGH,
|
CONF_TARGET_TEMPERATURE_HIGH,
|
||||||
CONF_TARGET_TEMPERATURE_LOW,
|
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 esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import template_ns
|
from .. import template_ns
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_CURRENT_HUMIDITY = "current_humidity"
|
CONF_CURRENT_HUMIDITY = "current_humidity"
|
||||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||||
CONF_SUPPORTS_ACTION = "supports_action"
|
CONF_SUPPORTS_ACTION = "supports_action"
|
||||||
@@ -190,6 +203,9 @@ def _validate_initial_state(config: ConfigType) -> ConfigType:
|
|||||||
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
||||||
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
||||||
)
|
)
|
||||||
|
convert_temperature_keys(
|
||||||
|
initial_state, climate.TARGET_TEMPERATURE_KEYS, entity_temperature_unit(config)
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -199,9 +215,9 @@ INITIAL_STATE_SCHEMA = cv.All(
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||||
cv.Exclusive(
|
cv.Exclusive(
|
||||||
@@ -216,7 +232,7 @@ INITIAL_STATE_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.All(
|
CONFIG_SCHEMA = cv.All(
|
||||||
climate.climate_schema(TemplateClimate)
|
climate.climate_schema(TemplateClimate, temperature_unit=True)
|
||||||
.extend(
|
.extend(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||||
@@ -290,6 +306,30 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_reports_in_the_entity_unit(config: ConfigType) -> ConfigType:
|
||||||
|
"""A sensor's state is copied as is, so a temperature sensor must report in the climate's unit."""
|
||||||
|
if (sensor_id := config.get(CONF_SENSOR)) is None:
|
||||||
|
return config
|
||||||
|
unit = entity_temperature_unit(config)
|
||||||
|
sensor_unit = resolve_inherited_property(sensor_id, CONF_UNIT_OF_MEASUREMENT)
|
||||||
|
if sensor_unit is UNRESOLVED_PROPERTY:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not determine the unit of sensor '%s'; it must report in %s",
|
||||||
|
sensor_id,
|
||||||
|
unit,
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
if sensor_unit in TEMPERATURE_UNITS and sensor_unit != unit:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"'{CONF_SENSOR}' reports in {sensor_unit} but the climate reports in {unit}",
|
||||||
|
path=[CONF_SENSOR],
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = _sensor_reports_in_the_entity_unit
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
@@ -367,11 +407,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(): cv.use_id(TemplateClimate),
|
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||||
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
||||||
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
||||||
@@ -400,16 +440,13 @@ automation.register_apply_action(
|
|||||||
"climate.template.publish",
|
"climate.template.publish",
|
||||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||||
automation.ApplyField(
|
automation.ApplyField(
|
||||||
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
|
CONF_CURRENT_TEMPERATURE,
|
||||||
|
"current_temperature = {}",
|
||||||
|
cg.float_,
|
||||||
|
temperature_in_entity_unit,
|
||||||
),
|
),
|
||||||
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
|
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
|
||||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
*climate.TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||||
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_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||||
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
|
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
|
||||||
automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction),
|
automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "esphome/core/automation.h"
|
#include "esphome/core/automation.h"
|
||||||
#include "esphome/core/component.h"
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/core/helpers.h"
|
||||||
#include "esphome/components/climate/climate.h"
|
#include "esphome/components/climate/climate.h"
|
||||||
#ifdef USE_SENSOR
|
#ifdef USE_SENSOR
|
||||||
#include "esphome/components/sensor/sensor.h"
|
#include "esphome/components/sensor/sensor.h"
|
||||||
@@ -22,6 +23,12 @@ class TemplateClimate final : public climate::Climate, public Component {
|
|||||||
climate::ClimateTraits traits() override { return this->traits_; }
|
climate::ClimateTraits traits() override { return this->traits_; }
|
||||||
|
|
||||||
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
|
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
|
#ifdef USE_SENSOR
|
||||||
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
|||||||
CONF_TARGET_TEMPERATURE,
|
CONF_TARGET_TEMPERATURE,
|
||||||
)
|
)
|
||||||
from esphome.core import ID
|
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.cpp_generator import MockObj, TemplateArgsType
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ RESTORE_MODES = TemplateWaterHeaterRestoreMode.options(
|
|||||||
)
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = (
|
CONFIG_SCHEMA = (
|
||||||
water_heater.water_heater_schema(TemplateWaterHeater)
|
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
|
||||||
.extend(
|
.extend(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||||
@@ -122,8 +123,8 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
|
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
|
||||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||||
cv.Optional(CONF_MODE): cv.templatable(
|
cv.Optional(CONF_MODE): cv.templatable(
|
||||||
water_heater.validate_water_heater_mode
|
water_heater.validate_water_heater_mode
|
||||||
),
|
),
|
||||||
@@ -142,13 +143,16 @@ async def water_heater_template_publish_to_code(
|
|||||||
var = cg.new_Pvariable(action_id, template_arg)
|
var = cg.new_Pvariable(action_id, template_arg)
|
||||||
await cg.register_parented(var, config[CONF_ID])
|
await cg.register_parented(var, config[CONF_ID])
|
||||||
|
|
||||||
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
|
unit = entity_temperature_unit_of_id(config[CONF_ID])
|
||||||
template_ = await cg.templatable(current_temp, args, cg.float_)
|
for key, setter in (
|
||||||
cg.add(var.set_current_temperature(template_))
|
(CONF_CURRENT_TEMPERATURE, var.set_current_temperature),
|
||||||
|
(CONF_TARGET_TEMPERATURE, var.set_target_temperature),
|
||||||
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
|
):
|
||||||
template_ = await cg.templatable(target_temp, args, cg.float_)
|
if (value := config.get(key)) is None:
|
||||||
cg.add(var.set_target_temperature(template_))
|
continue
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
value = cv.convert_temperature(*value, unit)
|
||||||
|
cg.add(setter(await cg.templatable(value, args, cg.float_)))
|
||||||
|
|
||||||
if mode := config.get(CONF_MODE):
|
if mode := config.get(CONF_MODE):
|
||||||
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
|
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
traits.set_supports_current_temperature(true);
|
traits.set_supports_current_temperature(true);
|
||||||
|
traits.set_temperature_unit(this->temperature_unit_);
|
||||||
if (this->target_temperature_f_.has_value()) {
|
if (this->target_temperature_f_.has_value()) {
|
||||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
|||||||
|
|
||||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||||
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
|
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) {
|
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
|
||||||
this->supported_modes_ = modes;
|
this->supported_modes_ = modes;
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
|||||||
TemplateLambda<bool> away_f_;
|
TemplateLambda<bool> away_f_;
|
||||||
TemplateLambda<bool> is_on_f_;
|
TemplateLambda<bool> is_on_f_;
|
||||||
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
|
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
|
||||||
|
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
|
||||||
water_heater::WaterHeaterModeMask supported_modes_;
|
water_heater::WaterHeaterModeMask supported_modes_;
|
||||||
bool optimistic_{true};
|
bool optimistic_{true};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,23 +4,16 @@ import esphome.config_validation as cv
|
|||||||
from esphome.const import CONF_SOURCE_ID
|
from esphome.const import CONF_SOURCE_ID
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import Text, text_ns
|
from .. import Text
|
||||||
|
|
||||||
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
|
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
|
||||||
CONFIG_SCHEMA = (
|
}
|
||||||
text_sensor.text_sensor_schema(TextTextSensor)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.extend(cv.COMPONENT_SCHEMA)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
source = await cg.get_variable(config[CONF_SOURCE_ID])
|
var = await text_sensor.new_text_sensor(config)
|
||||||
var = await text_sensor.new_text_sensor(config, source)
|
# Text has the same string state callback as TextSensor
|
||||||
await cg.register_component(var, config)
|
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#include "text_text_sensor.h"
|
|
||||||
#include "esphome/core/log.h"
|
|
||||||
|
|
||||||
namespace esphome::text {
|
|
||||||
|
|
||||||
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
|
|
||||||
|
|
||||||
void TextTextSensor::setup() {
|
|
||||||
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
|
|
||||||
if (this->source_->has_state())
|
|
||||||
this->publish_state(this->source_->state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
|
|
||||||
|
|
||||||
} // namespace esphome::text
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "../text.h"
|
|
||||||
#include "esphome/core/component.h"
|
|
||||||
#include "esphome/components/text_sensor/text_sensor.h"
|
|
||||||
|
|
||||||
namespace esphome::text {
|
|
||||||
|
|
||||||
class TextTextSensor final : public text_sensor::TextSensor, public Component {
|
|
||||||
public:
|
|
||||||
explicit TextTextSensor(Text *source) : source_(source) {}
|
|
||||||
void setup() override;
|
|
||||||
void dump_config() override;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
Text *source_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace esphome::text
|
|
||||||
@@ -22,6 +22,7 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||||
from esphome.core.entity_helpers import (
|
from esphome.core.entity_helpers import (
|
||||||
|
FollowEntity,
|
||||||
SubEntities,
|
SubEntities,
|
||||||
entity_duplicate_validator,
|
entity_duplicate_validator,
|
||||||
queue_entity_register,
|
queue_entity_register,
|
||||||
@@ -238,6 +239,12 @@ def sub_text_sensors(
|
|||||||
return SubEntities(new_text_sensor, config, parent)
|
return SubEntities(new_text_sensor, config, parent)
|
||||||
|
|
||||||
|
|
||||||
|
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
|
||||||
|
follow_text_sensor = FollowEntity(
|
||||||
|
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.CORE)
|
@coroutine_with_priority(CoroPriority.CORE)
|
||||||
async def to_code(config):
|
async def to_code(config):
|
||||||
cg.add_global(text_sensor_ns.using)
|
cg.add_global(text_sensor_ns.using)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from functools import partial
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -6,15 +8,19 @@ from esphome.const import (
|
|||||||
CONF_ID,
|
CONF_ID,
|
||||||
CONF_MAX_TEMPERATURE,
|
CONF_MAX_TEMPERATURE,
|
||||||
CONF_MIN_TEMPERATURE,
|
CONF_MIN_TEMPERATURE,
|
||||||
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
CONF_VISUAL,
|
CONF_VISUAL,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||||
from esphome.core.entity_helpers import (
|
from esphome.core.entity_helpers import (
|
||||||
|
convert_visual_temperatures,
|
||||||
entity_duplicate_validator,
|
entity_duplicate_validator,
|
||||||
queue_entity_register,
|
queue_entity_register,
|
||||||
setup_entity,
|
setup_entity,
|
||||||
|
setup_entity_temperature_unit,
|
||||||
)
|
)
|
||||||
from esphome.cpp_generator import MockObjClass
|
from esphome.cpp_generator import MockObjClass
|
||||||
|
import esphome.final_validate as fv
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@esphome/core"]
|
CODEOWNERS = ["@esphome/core"]
|
||||||
@@ -42,19 +48,36 @@ WATER_HEATER_MODES = WaterHeaterMode.options(
|
|||||||
)
|
)
|
||||||
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
|
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(
|
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
|
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
|
_WATER_HEATER_SCHEMA.add_extra(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(
|
def water_heater_schema(
|
||||||
@@ -62,8 +85,13 @@ def water_heater_schema(
|
|||||||
*,
|
*,
|
||||||
icon: str = cv.UNDEFINED,
|
icon: str = cv.UNDEFINED,
|
||||||
entity_category: str = cv.UNDEFINED,
|
entity_category: str = cv.UNDEFINED,
|
||||||
|
temperature_unit: bool = False,
|
||||||
) -> cv.Schema:
|
) -> 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_)}
|
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 [
|
for key, default, validator in [
|
||||||
(CONF_ICON, icon, cv.icon),
|
(CONF_ICON, icon, cv.icon),
|
||||||
@@ -88,6 +116,7 @@ async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> Non
|
|||||||
if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None:
|
if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None:
|
||||||
cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES")
|
cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES")
|
||||||
cg.add(var.set_visual_target_temperature_step_override(temp_step))
|
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:
|
async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable:
|
||||||
|
|||||||
@@ -218,13 +218,16 @@ WIFI_8266_PHY_MODES = WiFi8266PhyMode.options(
|
|||||||
)
|
)
|
||||||
|
|
||||||
WIFI_AP_MODE_FALLBACK = "FALLBACK"
|
WIFI_AP_MODE_FALLBACK = "FALLBACK"
|
||||||
|
WIFI_AP_MODE_ALWAYS_ON = "ALWAYS_ON"
|
||||||
WIFI_AP_MODE_WISP = "WISP"
|
WIFI_AP_MODE_WISP = "WISP"
|
||||||
|
|
||||||
|
|
||||||
def _validate_ap_mode(value: str) -> str:
|
def _validate_ap_mode(value: str) -> str:
|
||||||
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
|
value = cv.one_of(
|
||||||
if value == WIFI_AP_MODE_WISP:
|
WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_ALWAYS_ON, WIFI_AP_MODE_WISP, upper=True
|
||||||
# The bridge is part of the ESP32 backend, on both of its frameworks
|
)(value)
|
||||||
|
if value != WIFI_AP_MODE_FALLBACK:
|
||||||
|
# The AP next to the station is part of the ESP32 backend, on both of its frameworks
|
||||||
cv.only_on_esp32(value)
|
cv.only_on_esp32(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -368,18 +371,21 @@ def wifi_network_ap(value):
|
|||||||
if value is None:
|
if value is None:
|
||||||
value = {}
|
value = {}
|
||||||
config = WIFI_NETWORK_AP(value)
|
config = WIFI_NETWORK_AP(value)
|
||||||
if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
ap_mode = config[CONF_AP_MODE]
|
||||||
if not config.get(CONF_PASSWORD):
|
if ap_mode == WIFI_AP_MODE_WISP and not config.get(CONF_PASSWORD):
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
"WISP mode requires a password for the access point",
|
"WISP mode requires a password for the access point",
|
||||||
path=[CONF_PASSWORD],
|
path=[CONF_PASSWORD],
|
||||||
)
|
)
|
||||||
if config[CONF_AP_TIMEOUT].total_milliseconds == 0:
|
if (
|
||||||
raise cv.Invalid(
|
ap_mode != WIFI_AP_MODE_FALLBACK
|
||||||
"ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0",
|
and config[CONF_AP_TIMEOUT].total_milliseconds == 0
|
||||||
path=[CONF_AP_TIMEOUT],
|
):
|
||||||
)
|
raise cv.Invalid(
|
||||||
elif CONF_DNS_SERVERS in config:
|
f"ap_timeout is how long ap_mode: {ap_mode} waits before a failed start is tried again; it cannot be 0",
|
||||||
|
path=[CONF_AP_TIMEOUT],
|
||||||
|
)
|
||||||
|
if ap_mode != WIFI_AP_MODE_WISP and CONF_DNS_SERVERS in config:
|
||||||
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
|
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
|
||||||
if CONF_MANUAL_IP in config and CORE.is_rp2:
|
if CONF_MANUAL_IP in config and CORE.is_rp2:
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
@@ -432,6 +438,15 @@ def wifi_has_sta(config: ConfigType) -> bool:
|
|||||||
return bool(config.get(CONF_NETWORKS))
|
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
|
# Components that save station credentials at run time
|
||||||
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
|
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
|
||||||
|
|
||||||
@@ -459,24 +474,34 @@ def final_validate(config):
|
|||||||
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
|
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
|
||||||
)
|
)
|
||||||
|
|
||||||
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
|
ap_mode = config[CONF_AP][CONF_AP_MODE] if has_ap else WIFI_AP_MODE_FALLBACK
|
||||||
|
ap_is_wisp = ap_mode == WIFI_AP_MODE_WISP
|
||||||
|
# The AP stays up next to an upstream: the station, or ethernet or modem
|
||||||
|
if ap_mode != WIFI_AP_MODE_FALLBACK and not _has_upstream(config, full_config):
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"ap_mode: {ap_mode} needs a station network (wifi.ssid or wifi.networks), "
|
||||||
|
"or ethernet or modem as the upstream interface"
|
||||||
|
)
|
||||||
|
# The scan after a connect would take the AP down again; with no way for a
|
||||||
|
# network to appear at run time there is nothing for it to roam to.
|
||||||
|
if (
|
||||||
|
ap_mode == WIFI_AP_MODE_ALWAYS_ON
|
||||||
|
and config[CONF_POST_CONNECT_ROAMING]
|
||||||
|
and not wifi_stays_ap_only(config, full_config)
|
||||||
|
):
|
||||||
|
raise cv.Invalid(
|
||||||
|
"ap_mode: ALWAYS_ON is incompatible with post_connect_roaming. "
|
||||||
|
"Please set post_connect_roaming: false in the wifi block."
|
||||||
|
)
|
||||||
if ap_is_wisp:
|
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
|
# Every client packet passes through the WiFi driver, none through the device's own sockets
|
||||||
require_high_performance_wifi()
|
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]:
|
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
|
# The access point passes the upstream's resolvers on, which a static address has to name
|
||||||
upstreams = [
|
upstreams = [
|
||||||
@@ -845,10 +870,15 @@ async def to_code(config):
|
|||||||
# Skip the setter when the config matches the C++ initializer.
|
# Skip the setter when the config matches the C++ initializer.
|
||||||
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
|
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
|
||||||
cg.add(var.set_ap_timeout(ap_timeout))
|
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:
|
if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
||||||
cg.add_define("USE_WIFI_AP_WISP")
|
cg.add_define("USE_WIFI_AP_WISP")
|
||||||
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
|
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
|
||||||
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", 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):
|
if dns_servers := conf.get(CONF_DNS_SERVERS):
|
||||||
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
|
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
|
||||||
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
|
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
|
||||||
|
|||||||
@@ -404,6 +404,17 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
|
|||||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
|
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
|
||||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
|
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
|
||||||
#endif
|
#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
|
/// 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
|
/// Longer interval gives users time to configure WiFi without constant connection attempts
|
||||||
@@ -657,18 +668,19 @@ void WiFiComponent::setup() {
|
|||||||
this->configured_power_save_ = this->power_save_;
|
this->configured_power_save_ = this->power_save_;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
|
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || defined(WIFI_AP_HAS_PORTAL))
|
||||||
// The access point, the portals on it and the DPP listen are provisioning surfaces: once the
|
// 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
|
// window has closed, end them (mirrors the teardown on a successful connection). The blocks in
|
||||||
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
|
// loop() that start them are gated so none of them is started again afterwards. An AP that
|
||||||
// carries no portal and stays up.
|
// stays up only loses its portal.
|
||||||
if (provisioning::global_provisioning_manager != nullptr) {
|
if (provisioning::global_provisioning_manager != nullptr) {
|
||||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||||
#ifdef USE_WIFI_DPP
|
#ifdef USE_WIFI_DPP
|
||||||
this->stop_dpp_listen_();
|
this->stop_dpp_listen_();
|
||||||
#endif
|
#endif
|
||||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
#ifdef WIFI_AP_HAS_PORTAL
|
||||||
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
|
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_) {
|
if (this->ap_setup_) {
|
||||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||||
@@ -681,7 +693,8 @@ void WiFiComponent::setup() {
|
|||||||
#endif
|
#endif
|
||||||
this->disable_ap_();
|
this->disable_ap_();
|
||||||
}
|
}
|
||||||
#endif // USE_WIFI_AP
|
#endif // !USE_WIFI_AP_PERSISTENT
|
||||||
|
#endif // WIFI_AP_HAS_PORTAL
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -783,18 +796,12 @@ void WiFiComponent::start() {
|
|||||||
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
|
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
|
||||||
this->start_initial_connection_();
|
this->start_initial_connection_();
|
||||||
#endif
|
#endif
|
||||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
#ifdef WIFI_AP_HAS_PORTAL
|
||||||
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
|
} else if (this->has_ap()) { // a bridge without a portal waits in loop() for the upstream's resolver
|
||||||
this->setup_ap_config_();
|
this->setup_ap_config_();
|
||||||
#ifdef USE_CAPTIVE_PORTAL
|
this->start_portal_scan_();
|
||||||
// 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_();
|
this->start_ap_portal_();
|
||||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
#endif // WIFI_AP_HAS_PORTAL
|
||||||
}
|
}
|
||||||
#ifdef USE_WIFI_DPP
|
#ifdef USE_WIFI_DPP
|
||||||
// Without networks there is nothing to try first, so listen for a configurator right away
|
// Without networks there is nothing to try first, so listen for a configurator right away
|
||||||
@@ -934,7 +941,7 @@ void WiFiComponent::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_PERSISTENT)
|
||||||
// Once the provisioning window has closed, don't bring up the fallback AP (or
|
// 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
|
// the captive portal on it) - the device must stay unprovisionable until it is
|
||||||
// power-cycled.
|
// power-cycled.
|
||||||
@@ -964,7 +971,13 @@ void WiFiComponent::loop() {
|
|||||||
this->start_ap_portal_();
|
this->start_ap_portal_();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
#endif // USE_WIFI_AP && !USE_WIFI_AP_PERSISTENT
|
||||||
|
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
|
||||||
|
if (this->ap_setup_ && !this->is_connected() && now - this->last_connected_ > WIFI_PORTAL_REOPEN_DELAY_MS &&
|
||||||
|
!this->is_provisioning_closed_() && !this->is_ap_portal_active_()) {
|
||||||
|
this->start_ap_portal_();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef USE_IMPROV_BLE
|
#ifdef USE_IMPROV_BLE
|
||||||
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
|
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
|
||||||
@@ -1002,13 +1015,24 @@ void WiFiComponent::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_WIFI_AP_WISP
|
#ifdef USE_WIFI_AP_PERSISTENT
|
||||||
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
|
// The AP stays up next to the upstream, so it is started with any upstream; a start that failed is
|
||||||
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
|
// tried again after ap_timeout. A bridge without a portal waits for a resolver to offer.
|
||||||
|
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
|
||||||
|
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_PORTAL)
|
||||||
|
this->ap_dns_known_() &&
|
||||||
|
#endif
|
||||||
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
|
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
|
||||||
ESP_LOGI(TAG, "Starting AP");
|
ESP_LOGI(TAG, "Starting AP");
|
||||||
this->ap_changed_ = now;
|
this->ap_changed_ = now;
|
||||||
this->setup_ap_config_();
|
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
|
#endif
|
||||||
|
|
||||||
@@ -1102,9 +1126,9 @@ void WiFiComponent::setup_ap_config_() {
|
|||||||
!this->wifi_apply_output_power_(this->output_power_)) {
|
!this->wifi_apply_output_power_(this->output_power_)) {
|
||||||
ESP_LOGV(TAG, "Setting Output Power Option failed");
|
ESP_LOGV(TAG, "Setting Output Power Option failed");
|
||||||
}
|
}
|
||||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||||
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
|
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
|
||||||
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
|
// the STA attempts until the next try, and an AP that stays up would take clients it cannot serve.
|
||||||
if (!this->ap_setup_)
|
if (!this->ap_setup_)
|
||||||
this->wifi_mode_({}, false);
|
this->wifi_mode_({}, false);
|
||||||
#endif
|
#endif
|
||||||
@@ -1153,24 +1177,23 @@ void WiFiComponent::pause_exclusive_ap_() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_WIFI_AP_WISP
|
void WiFiComponent::forget_ap_() {
|
||||||
void WiFiComponent::fail_bridge_() {
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
this->ap_setup_ = false;
|
this->ap_offers_own_dns_ = false; // the AP goes down, so ending its portal rewrites nothing
|
||||||
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
|
this->cancel_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID); // the next AP setup works out its clients' resolver itself
|
||||||
this->wifi_mode_({}, false);
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
void WiFiComponent::disable_ap_() {
|
|
||||||
this->end_ap_portal_();
|
this->end_ap_portal_();
|
||||||
ESP_LOGD(TAG, "Disabling AP");
|
// The AP may come back later; its clients dropped with it without a disconnect event for each
|
||||||
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_setup_ = false;
|
||||||
this->ap_clients_ = 0;
|
this->ap_clients_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WiFiComponent::disable_ap_() {
|
||||||
|
this->forget_ap_();
|
||||||
|
ESP_LOGD(TAG, "Disabling AP");
|
||||||
|
this->wifi_mode_({}, false);
|
||||||
|
}
|
||||||
|
|
||||||
void WiFiComponent::set_ap(const WiFiAP &ap) {
|
void WiFiComponent::set_ap(const WiFiAP &ap) {
|
||||||
this->ap_ = ap;
|
this->ap_ = ap;
|
||||||
this->has_ap_ = true;
|
this->has_ap_ = true;
|
||||||
@@ -1503,14 +1526,12 @@ void WiFiComponent::disable() {
|
|||||||
this->stop_dpp_listen_();
|
this->stop_dpp_listen_();
|
||||||
#endif
|
#endif
|
||||||
this->wifi_disconnect_();
|
this->wifi_disconnect_();
|
||||||
this->wifi_mode_(false, false);
|
|
||||||
#ifdef USE_WIFI_AP
|
#ifdef USE_WIFI_AP
|
||||||
// The AP went down with the radio; enable() has to configure it, and its portal, again
|
// The AP goes down with the radio; enable() has to configure it, and its portal, again
|
||||||
this->end_ap_portal_();
|
this->forget_ap_();
|
||||||
this->ap_setup_ = false;
|
|
||||||
this->ap_clients_ = 0;
|
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_WIFI_AP_WISP
|
this->wifi_mode_(false, false);
|
||||||
|
#ifdef USE_WIFI_AP_PERSISTENT
|
||||||
this->ap_changed_ = 0; // at once, not after ap_timeout
|
this->ap_changed_ = 0; // at once, not after ap_timeout
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -1809,9 +1830,13 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
|||||||
// Reset to initial phase on successful connection (don't log transition, just reset state)
|
// Reset to initial phase on successful connection (don't log transition, just reset state)
|
||||||
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
|
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
|
||||||
this->num_retried_ = 0;
|
this->num_retried_ = 0;
|
||||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
|
#ifdef WIFI_AP_HAS_PORTAL
|
||||||
if (this->has_ap()) {
|
if (this->has_ap()) {
|
||||||
|
#ifdef USE_WIFI_AP_PERSISTENT
|
||||||
|
this->end_ap_portal_(); // the AP stays up for its clients
|
||||||
|
#else
|
||||||
this->disable_ap_();
|
this->disable_ap_();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_WIFI_DPP
|
#ifdef USE_WIFI_DPP
|
||||||
@@ -2452,8 +2477,23 @@ void WiFiComponent::start_ap_portal_() {
|
|||||||
#ifdef USE_WEBSERVER_CAPTIVE
|
#ifdef USE_WEBSERVER_CAPTIVE
|
||||||
web_server::global_web_server->start_captive();
|
web_server::global_web_server->start_captive();
|
||||||
#endif
|
#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_() {
|
void WiFiComponent::end_ap_portal_() {
|
||||||
#ifdef USE_CAPTIVE_PORTAL
|
#ifdef USE_CAPTIVE_PORTAL
|
||||||
if (this->is_captive_portal_active_())
|
if (this->is_captive_portal_active_())
|
||||||
@@ -2462,6 +2502,10 @@ void WiFiComponent::end_ap_portal_() {
|
|||||||
#ifdef USE_WEBSERVER_CAPTIVE
|
#ifdef USE_WEBSERVER_CAPTIVE
|
||||||
web_server::global_web_server->end_captive();
|
web_server::global_web_server->end_captive();
|
||||||
#endif
|
#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_() {
|
bool WiFiComponent::is_improv_ble_active_() {
|
||||||
#ifdef USE_IMPROV_BLE
|
#ifdef USE_IMPROV_BLE
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ class WiFiPowerSaveListener {
|
|||||||
// Scheduler ids
|
// Scheduler ids
|
||||||
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
|
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
|
||||||
static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1;
|
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.
|
/// This component is responsible for managing the ESP WiFi interface.
|
||||||
class WiFiComponent final : public Component {
|
class WiFiComponent final : public Component {
|
||||||
@@ -739,6 +740,8 @@ class WiFiComponent final : public Component {
|
|||||||
void setup_ap_config_();
|
void setup_ap_config_();
|
||||||
/// End the portals and turn the AP off.
|
/// End the portals and turn the AP off.
|
||||||
void disable_ap_();
|
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
|
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||||
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
|
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
|
||||||
void pause_exclusive_ap_();
|
void pause_exclusive_ap_();
|
||||||
@@ -750,11 +753,16 @@ class WiFiComponent final : public Component {
|
|||||||
bool ap_dns_known_();
|
bool ap_dns_known_();
|
||||||
/// Hand the AP DHCP server its resolvers; false when one could not be set
|
/// Hand the AP DHCP server its resolvers; false when one could not be set
|
||||||
bool write_ap_dns_();
|
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
|
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||||
/// Pass the station's resolvers on to AP clients when they changed
|
/// Pass the station's resolvers on to AP clients when they changed
|
||||||
void apply_ap_dns_();
|
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
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -883,6 +891,10 @@ class WiFiComponent final : public Component {
|
|||||||
bool is_ap_portal_active_();
|
bool is_ap_portal_active_();
|
||||||
void start_ap_portal_();
|
void start_ap_portal_();
|
||||||
void end_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_();
|
bool is_improv_ble_active_();
|
||||||
|
|
||||||
#ifdef USE_WIFI_FAST_CONNECT
|
#ifdef USE_WIFI_FAST_CONNECT
|
||||||
@@ -1030,7 +1042,7 @@ class WiFiComponent final : public Component {
|
|||||||
float output_power_{NAN};
|
float output_power_{NAN};
|
||||||
uint32_t action_started_;
|
uint32_t action_started_;
|
||||||
uint32_t last_connected_{0};
|
uint32_t last_connected_{0};
|
||||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||||
uint32_t ap_changed_{0}; // When the AP was last started or paused
|
uint32_t ap_changed_{0}; // When the AP was last started or paused
|
||||||
#endif
|
#endif
|
||||||
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
|
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
|
||||||
@@ -1149,6 +1161,10 @@ class WiFiComponent final : public Component {
|
|||||||
// Fallback AP starts that failed since the last connection; each one waits another ap_timeout
|
// Fallback AP starts that failed since the last connection; each one waits another ap_timeout
|
||||||
uint8_t ap_start_failures_{0};
|
uint8_t ap_start_failures_{0};
|
||||||
#endif
|
#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
|
#endif
|
||||||
bool passive_scan_{false};
|
bool passive_scan_{false};
|
||||||
bool has_saved_wifi_settings_{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.
|
// 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) {
|
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));
|
ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err));
|
||||||
this->fail_bridge_();
|
this->disable_ap_();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -1471,8 +1471,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_WIFI_AP_WISP
|
#ifdef USE_WIFI_AP_WISP
|
||||||
// Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would
|
// Offer resolvers through DHCP: the configured ones, otherwise the station's (with a portal, the AP's own
|
||||||
// offer the AP's own address, so a failure fails the setup and it is tried again.
|
// address while there is none). A failure fails the setup and it is tried again.
|
||||||
uint8_t dns_offer = 1;
|
uint8_t dns_offer = 1;
|
||||||
err =
|
err =
|
||||||
esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer));
|
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
|
#endif
|
||||||
|
|
||||||
static uint32_t ap_dns_to_offer(size_t i) {
|
static uint32_t upstream_dns(size_t i) {
|
||||||
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
|
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
|
||||||
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
|
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
|
||||||
#else
|
#else
|
||||||
@@ -1660,14 +1660,45 @@ static uint32_t ap_dns_to_offer(size_t i) {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
|
static uint32_t ap_dns_to_offer(size_t i, [[maybe_unused]] bool own) {
|
||||||
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
|
if (own) {
|
||||||
|
esp_netif_ip_info_t ip{};
|
||||||
|
if (s_ap_netif != nullptr && esp_netif_get_ip_info(s_ap_netif, &ip) == ESP_OK)
|
||||||
|
return ip.ip.addr;
|
||||||
|
ESP_LOGW(TAG, "Failed to read the AP address to offer as resolver");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// A slot the upstream leaves empty repeats the primary, so the AP's own address is replaced there too
|
||||||
|
const uint32_t addr = upstream_dns(i);
|
||||||
|
return addr != 0 ? addr : upstream_dns(0);
|
||||||
|
#else
|
||||||
|
return upstream_dns(i);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WiFiComponent::ap_dns_known_() { return upstream_dns(0) != 0; }
|
||||||
|
|
||||||
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
|
bool WiFiComponent::offer_own_dns_() {
|
||||||
|
return !this->ap_dns_known_() || (this->has_sta() && this->is_ap_portal_active_());
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
bool WiFiComponent::write_ap_dns_() {
|
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;
|
bool ok = true;
|
||||||
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
|
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
|
||||||
const uint32_t addr = ap_dns_to_offer(i);
|
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||||
if (addr == 0)
|
if (addr == 0) {
|
||||||
|
ok = ok && !own; // the AP's own address could not be read
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
esp_netif_dns_info_t info{};
|
esp_netif_dns_info_t info{};
|
||||||
info.ip.type = ESP_IPADDR_TYPE_V4;
|
info.ip.type = ESP_IPADDR_TYPE_V4;
|
||||||
info.ip.u_addr.ip4.addr = addr;
|
info.ip.u_addr.ip4.addr = addr;
|
||||||
@@ -1685,28 +1716,65 @@ bool WiFiComponent::write_ap_dns_() {
|
|||||||
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||||
void WiFiComponent::apply_ap_dns_() {
|
void WiFiComponent::apply_ap_dns_() {
|
||||||
if (!this->ap_setup_)
|
if (!this->ap_setup_)
|
||||||
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
|
return; // loop() brings the AP up, and its setup writes the resolvers
|
||||||
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
|
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
|
||||||
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
|
// next lease. A resolver the station lost stays offered (set_dns_info rejects an empty address), or with a
|
||||||
|
// portal gives way to the AP's own address.
|
||||||
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
|
const bool own = this->offer_own_dns_();
|
||||||
|
#else
|
||||||
|
const bool own = false;
|
||||||
|
#endif
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
|
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
|
||||||
const uint32_t addr = ap_dns_to_offer(i);
|
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||||
esp_netif_dns_info_t current{};
|
esp_netif_dns_info_t current{};
|
||||||
|
// A missing own address goes through the setup too, which then fails and restarts the AP
|
||||||
changed =
|
changed =
|
||||||
addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), ¤t) != ESP_OK ||
|
(addr == 0 && own) ||
|
||||||
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)
|
if (!changed)
|
||||||
return;
|
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
|
// The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined
|
||||||
#ifdef USE_WIFI_MANUAL_IP
|
#ifdef USE_WIFI_MANUAL_IP
|
||||||
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
|
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
|
||||||
#else
|
#else
|
||||||
const bool ok = this->wifi_ap_ip_config_({});
|
const bool ok = this->wifi_ap_ip_config_({});
|
||||||
#endif
|
#endif
|
||||||
if (!ok)
|
if (!ok) {
|
||||||
this->fail_bridge_();
|
this->disable_ap_();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
|
if (offered_own != this->ap_offers_own_dns_)
|
||||||
|
this->drop_ap_clients_();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||||
|
void WiFiComponent::drop_ap_clients_(uint8_t attempt) {
|
||||||
|
static constexpr uint8_t MAX_ATTEMPTS = 5;
|
||||||
|
if (!this->ap_setup_)
|
||||||
|
return;
|
||||||
|
esp_err_t err = esp_wifi_deauth_sta(0);
|
||||||
|
if (err == ESP_OK)
|
||||||
|
return;
|
||||||
|
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||||
|
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; restarting AP",
|
||||||
|
esp_err_to_name(err));
|
||||||
|
this->disable_ap_(); // loop() starts it again, and the clients rejoin
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; retrying", esp_err_to_name(err));
|
||||||
|
this->set_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID, 1000, [this, attempt]() { this->drop_ap_clients_(attempt + 1); });
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif // USE_WIFI_AP_WISP_STATIC_DNS
|
#endif // USE_WIFI_AP_WISP_STATIC_DNS
|
||||||
#endif // USE_WIFI_AP_WISP
|
#endif // USE_WIFI_AP_WISP
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ _CALLBACK_AUTOMATIONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Above safe_mode, so Bluetooth OTA can still recover a device stuck in a boot loop
|
||||||
|
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
zephyr_add_prj_conf("BT", True)
|
zephyr_add_prj_conf("BT", True)
|
||||||
@@ -93,6 +95,9 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
zephyr_add_prj_conf("BT_SMP_SC_ONLY", True)
|
zephyr_add_prj_conf("BT_SMP_SC_ONLY", True)
|
||||||
zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True)
|
zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True)
|
||||||
request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum
|
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)
|
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,14 @@ void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) {
|
|||||||
snprintk(passkey_str, 7, "%06u", passkey);
|
snprintk(passkey_str, 7, "%06u", passkey);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str);
|
ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str);
|
||||||
global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); });
|
global_ble_server->defer([passkey]() {
|
||||||
|
if (global_ble_server->passkey_cb_.size() == 0) {
|
||||||
|
ESP_LOGW(TAG, "Nothing answers numeric comparison (safe mode skips the automation), rejecting pairing");
|
||||||
|
global_ble_server->numeric_comparison_reply(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
global_ble_server->passkey_cb_(passkey);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static void auth_pairing_confirm(bt_conn *conn) {
|
static void auth_pairing_confirm(bt_conn *conn) {
|
||||||
|
|||||||
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
|
|||||||
fv.full_config.reset(token)
|
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):
|
class PinUseValidationCheck(ConfigValidationStep):
|
||||||
"""Check for pin reuse"""
|
"""Check for pin reuse"""
|
||||||
|
|
||||||
@@ -1437,6 +1449,7 @@ def validate_config(
|
|||||||
result.add_validation_step(PrefetchRemoteFilesValidationStep())
|
result.add_validation_step(PrefetchRemoteFilesValidationStep())
|
||||||
result.add_validation_step(IDPassValidationStep())
|
result.add_validation_step(IDPassValidationStep())
|
||||||
result.add_validation_step(CoreFinalValidateStep())
|
result.add_validation_step(CoreFinalValidateStep())
|
||||||
|
result.add_validation_step(OptionalPinValidationStep())
|
||||||
result.add_validation_step(PinUseValidationCheck())
|
result.add_validation_step(PinUseValidationCheck())
|
||||||
|
|
||||||
result.add_validation_step(RemoveReferenceValidationStep())
|
result.add_validation_step(RemoveReferenceValidationStep())
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ from esphome.const import (
|
|||||||
SCHEDULER_DONT_RUN,
|
SCHEDULER_DONT_RUN,
|
||||||
TYPE_GIT,
|
TYPE_GIT,
|
||||||
TYPE_LOCAL,
|
TYPE_LOCAL,
|
||||||
|
UNIT_CELSIUS,
|
||||||
|
UNIT_FAHRENHEIT,
|
||||||
|
UNIT_KELVIN,
|
||||||
Framework,
|
Framework,
|
||||||
Toolchain,
|
Toolchain,
|
||||||
__version__ as ESPHOME_VERSION,
|
__version__ as ESPHOME_VERSION,
|
||||||
@@ -86,6 +89,7 @@ from esphome.core import (
|
|||||||
CORE,
|
CORE,
|
||||||
HexInt,
|
HexInt,
|
||||||
Lambda,
|
Lambda,
|
||||||
|
TemperatureWithUnit,
|
||||||
TimePeriod,
|
TimePeriod,
|
||||||
TimePeriodMicroseconds,
|
TimePeriodMicroseconds,
|
||||||
TimePeriodMilliseconds,
|
TimePeriodMilliseconds,
|
||||||
@@ -94,6 +98,7 @@ from esphome.core import (
|
|||||||
TimePeriodSeconds,
|
TimePeriodSeconds,
|
||||||
Version,
|
Version,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_types import TEMPERATURE_UNITS
|
||||||
from esphome.enum import StrEnum
|
from esphome.enum import StrEnum
|
||||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||||
from esphome.helpers import (
|
from esphome.helpers import (
|
||||||
@@ -1520,7 +1525,7 @@ METRIC_SUFFIXES = {
|
|||||||
|
|
||||||
def float_with_unit(quantity, regex_suffix, optional_unit=False):
|
def float_with_unit(quantity, regex_suffix, optional_unit=False):
|
||||||
pattern = re.compile(
|
pattern = re.compile(
|
||||||
f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
f"^([-+]?(?:[0-9]+\\.?[0-9]*|\\.[0-9]+))\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||||
)
|
)
|
||||||
|
|
||||||
def validator(value):
|
def validator(value):
|
||||||
@@ -1557,53 +1562,73 @@ distance = float_with_unit("distance", "(m)")
|
|||||||
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
|
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
|
||||||
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
|
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
|
||||||
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
|
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
|
||||||
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
|
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
|
||||||
_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)
|
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
|
||||||
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
|
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
|
||||||
|
|
||||||
|
|
||||||
def temperature(value):
|
class TemperatureUnitValue(str):
|
||||||
err = None
|
"""A unit from temperature_unit(): the platform took the option, so the entity reports in it."""
|
||||||
try:
|
|
||||||
return _temperature_c(value)
|
|
||||||
except Invalid as orig_err:
|
|
||||||
err = orig_err
|
|
||||||
|
|
||||||
try:
|
__slots__ = ()
|
||||||
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_delta(value):
|
def temperature_unit(value: Any) -> TemperatureUnitValue:
|
||||||
err = None
|
"""Validate the unit an entity reports its temperatures in."""
|
||||||
try:
|
return TemperatureUnitValue(one_of(*TEMPERATURE_UNITS)(value))
|
||||||
return _temperature_c(value)
|
|
||||||
except Invalid as orig_err:
|
|
||||||
err = orig_err
|
|
||||||
|
|
||||||
try:
|
|
||||||
return _temperature_k(value)
|
|
||||||
except Invalid:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
def temperature_with_unit(value: Any) -> TemperatureWithUnit:
|
||||||
fahrenheit = _temperature_f(value)
|
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
|
||||||
return fahrenheit * (5 / 9)
|
for validator, unit in (
|
||||||
except Invalid:
|
(_temperature_k, UNIT_KELVIN),
|
||||||
pass
|
(_temperature_f, UNIT_FAHRENHEIT),
|
||||||
|
(_temperature_c_strict, UNIT_CELSIUS),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return TemperatureWithUnit(validator(value), unit)
|
||||||
|
except Invalid:
|
||||||
|
pass
|
||||||
|
return TemperatureWithUnit(_temperature_c(value), None)
|
||||||
|
|
||||||
raise err
|
|
||||||
|
# unit = celsius * scale + offset
|
||||||
|
_TEMPERATURE_SCALES = {
|
||||||
|
UNIT_CELSIUS: (1.0, 0.0),
|
||||||
|
UNIT_FAHRENHEIT: (9 / 5, 32.0),
|
||||||
|
UNIT_KELVIN: (1.0, 273.15),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templatable_temperature = templatable(temperature_with_unit)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_temperature(
|
||||||
|
value: float, unit: str | None, to_unit: str, *, delta: bool = False
|
||||||
|
) -> float:
|
||||||
|
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
|
||||||
|
|
||||||
|
A value written without a unit is taken to be in ``to_unit`` already.
|
||||||
|
"""
|
||||||
|
if unit is None or unit == to_unit:
|
||||||
|
return value
|
||||||
|
scale, offset = _TEMPERATURE_SCALES[unit]
|
||||||
|
to_scale, to_offset = _TEMPERATURE_SCALES[to_unit]
|
||||||
|
if delta:
|
||||||
|
return value / scale * to_scale
|
||||||
|
return (value - offset) / scale * to_scale + to_offset
|
||||||
|
|
||||||
|
|
||||||
|
def temperature(value: Any) -> float:
|
||||||
|
"""Parse a temperature in °C, K or °F into degrees Celsius."""
|
||||||
|
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
|
||||||
|
|
||||||
|
|
||||||
|
def temperature_delta(value: Any) -> float:
|
||||||
|
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
|
||||||
|
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
|
||||||
|
|
||||||
|
|
||||||
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
|
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
|
||||||
|
|||||||
@@ -1269,6 +1269,7 @@ UNIT_DECIBEL_MILLIWATT = "dBm"
|
|||||||
UNIT_DEGREE_PER_SECOND = "°/s"
|
UNIT_DEGREE_PER_SECOND = "°/s"
|
||||||
UNIT_DEGREES = "°"
|
UNIT_DEGREES = "°"
|
||||||
UNIT_EMPTY = ""
|
UNIT_EMPTY = ""
|
||||||
|
UNIT_FAHRENHEIT = "°F"
|
||||||
UNIT_G = "G"
|
UNIT_G = "G"
|
||||||
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
|
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
|
||||||
UNIT_HECTOPASCAL = "hPa"
|
UNIT_HECTOPASCAL = "hPa"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import math
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_BUILD_PATH,
|
CONF_BUILD_PATH,
|
||||||
@@ -100,6 +100,16 @@ def is_approximately_integer(value):
|
|||||||
return abs(value - round(value)) < 0.001
|
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:
|
class TimePeriod:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -416,10 +416,13 @@ void Application::teardown_components(uint32_t timeout_ms) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Application::add_looping_components_by_state_(bool match_loop_done) {
|
void Application::add_looping_components_by_state_(bool inactive) {
|
||||||
for (auto *obj : this->components_) {
|
for (auto *obj : this->components_) {
|
||||||
if (obj->has_overridden_loop() &&
|
if (!obj->has_overridden_loop()) {
|
||||||
((obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) == match_loop_done) {
|
continue;
|
||||||
|
}
|
||||||
|
const uint8_t state = obj->get_component_state() & COMPONENT_STATE_MASK;
|
||||||
|
if ((state == COMPONENT_STATE_LOOP_DONE || state == COMPONENT_STATE_FAILED) == inactive) {
|
||||||
this->looping_components_.push_back(obj);
|
this->looping_components_.push_back(obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,16 +433,15 @@ class Application {
|
|||||||
// FixedVector capacity was pre-initialized by codegen with the exact count
|
// FixedVector capacity was pre-initialized by codegen with the exact count
|
||||||
// of components that override loop(), computed at C++ compile time.
|
// of components that override loop(), computed at C++ compile time.
|
||||||
|
|
||||||
// Add all components with loop override that aren't already LOOP_DONE
|
// Add all components with loop override that aren't already LOOP_DONE or FAILED
|
||||||
// Some components (like logger) may call disable_loop() during initialization
|
// Some components (like logger) may call disable_loop() during initialization,
|
||||||
// before setup runs, so we need to respect their LOOP_DONE state
|
// and some call mark_failed() from their constructor, before setup runs
|
||||||
this->add_looping_components_by_state_(false);
|
this->add_looping_components_by_state_(false);
|
||||||
this->looping_components_active_end_ = this->looping_components_.size();
|
this->looping_components_active_end_ = this->looping_components_.size();
|
||||||
// Then add any components that are already LOOP_DONE to the inactive section
|
// Then add any components that are already LOOP_DONE or FAILED to the inactive section
|
||||||
// This handles components that called disable_loop() during initialization
|
|
||||||
this->add_looping_components_by_state_(true);
|
this->add_looping_components_by_state_(true);
|
||||||
}
|
}
|
||||||
void add_looping_components_by_state_(bool match_loop_done);
|
void add_looping_components_by_state_(bool inactive);
|
||||||
|
|
||||||
// These methods are called by Component::disable_loop() and Component::enable_loop()
|
// These methods are called by Component::disable_loop() and Component::enable_loop()
|
||||||
// Components should not call these directly - use this->disable_loop() or this->enable_loop()
|
// Components should not call these directly - use this->disable_loop() or this->enable_loop()
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ struct Color {
|
|||||||
uint8_t r = rand >> 16;
|
uint8_t r = rand >> 16;
|
||||||
uint8_t g = rand >> 8;
|
uint8_t g = rand >> 8;
|
||||||
uint8_t b = rand >> 0;
|
uint8_t b = rand >> 0;
|
||||||
const uint16_t max_rgb = std::max({r, g, b});
|
const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
|
||||||
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
|
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
|
||||||
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
|
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,7 @@
|
|||||||
#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host
|
#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host
|
||||||
#define USE_MQTT
|
#define USE_MQTT
|
||||||
#define USE_MQTT_ABBREVIATIONS
|
#define USE_MQTT_ABBREVIATIONS
|
||||||
|
#define USE_MQTT_SESSION_PERSISTENCE
|
||||||
#define USE_MQTT_COVER_JSON
|
#define USE_MQTT_COVER_JSON
|
||||||
#define USE_WIREGUARD
|
#define USE_WIREGUARD
|
||||||
#endif
|
#endif
|
||||||
@@ -554,10 +555,9 @@
|
|||||||
#define USE_WIFI_FAST_CONNECT_IN_FLASH
|
#define USE_WIFI_FAST_CONNECT_IN_FLASH
|
||||||
#define USE_WIFI_LEASE_CACHE
|
#define USE_WIFI_LEASE_CACHE
|
||||||
#define USE_WIFI_LEASE_CACHE_IN_FLASH
|
#define USE_WIFI_LEASE_CACHE_IN_FLASH
|
||||||
|
#define USE_WIFI_AP_PERSISTENT
|
||||||
#define USE_WIFI_AP_WISP
|
#define USE_WIFI_AP_WISP
|
||||||
#define USE_WIFI_AP_WISP_STATIC_DNS
|
#define USE_WIFI_AP_WISP_PORTAL
|
||||||
#define WIFI_AP_WISP_DNS_0 0x08080808
|
|
||||||
#define WIFI_AP_WISP_DNS_1 0x04040808
|
|
||||||
#define USE_WIFI_PHY_MODE
|
#define USE_WIFI_PHY_MODE
|
||||||
#define USE_WIFI_IP_STATE_LISTENERS
|
#define USE_WIFI_IP_STATE_LISTENERS
|
||||||
#define USE_WIFI_SCAN_RESULTS_LISTENERS
|
#define USE_WIFI_SCAN_RESULTS_LISTENERS
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Iterator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import functools
|
import functools
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
@@ -20,18 +20,23 @@ from esphome.const import (
|
|||||||
CONF_ICON,
|
CONF_ICON,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
CONF_INTERNAL,
|
CONF_INTERNAL,
|
||||||
|
CONF_MAX_TEMPERATURE,
|
||||||
|
CONF_MIN_TEMPERATURE,
|
||||||
CONF_MQTT_ID,
|
CONF_MQTT_ID,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
|
CONF_PLATFORM,
|
||||||
CONF_REPORT,
|
CONF_REPORT,
|
||||||
CONF_SORTING_GROUP_ID,
|
CONF_SORTING_GROUP_ID,
|
||||||
CONF_SORTING_WEIGHT,
|
CONF_SORTING_WEIGHT,
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
CONF_USE_DEVICE_TYPE,
|
CONF_USE_DEVICE_TYPE,
|
||||||
|
CONF_VISUAL,
|
||||||
CONF_WEB_SERVER,
|
CONF_WEB_SERVER,
|
||||||
CONF_WEB_SERVER_ID,
|
CONF_WEB_SERVER_ID,
|
||||||
CONF_ZIGBEE_BINARY_SENSOR,
|
CONF_ZIGBEE_BINARY_SENSOR,
|
||||||
CONF_ZIGBEE_ID,
|
CONF_ZIGBEE_ID,
|
||||||
CONF_ZIGBEE_SENSOR,
|
CONF_ZIGBEE_SENSOR,
|
||||||
|
UNIT_CELSIUS,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||||
from esphome.core.config import (
|
from esphome.core.config import (
|
||||||
@@ -40,6 +45,7 @@ from esphome.core.config import (
|
|||||||
UNIT_OF_MEASUREMENT_MAX_LENGTH,
|
UNIT_OF_MEASUREMENT_MAX_LENGTH,
|
||||||
)
|
)
|
||||||
from esphome.cpp_generator import (
|
from esphome.cpp_generator import (
|
||||||
|
LambdaExpression,
|
||||||
MockObj,
|
MockObj,
|
||||||
MockObjClass,
|
MockObjClass,
|
||||||
RawExpression,
|
RawExpression,
|
||||||
@@ -47,7 +53,7 @@ from esphome.cpp_generator import (
|
|||||||
add,
|
add,
|
||||||
get_variable,
|
get_variable,
|
||||||
)
|
)
|
||||||
from esphome.cpp_types import App
|
from esphome.cpp_types import TEMPERATURE_UNITS, App
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
from esphome.helpers import (
|
from esphome.helpers import (
|
||||||
cpp_string_escape,
|
cpp_string_escape,
|
||||||
@@ -56,6 +62,7 @@ from esphome.helpers import (
|
|||||||
sanitize,
|
sanitize,
|
||||||
snake_case,
|
snake_case,
|
||||||
)
|
)
|
||||||
|
from esphome.loader import get_component, get_platform
|
||||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||||
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
||||||
|
|
||||||
@@ -513,6 +520,23 @@ class ApplyEntity:
|
|||||||
_APPLY_DOMAIN = "entity_apply"
|
_APPLY_DOMAIN = "entity_apply"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FollowEntity:
|
||||||
|
"""A state callback, its parameters and the value an entity republishes from it."""
|
||||||
|
|
||||||
|
callback: str
|
||||||
|
params: tuple[tuple[SafeExpType, str], ...]
|
||||||
|
value: str
|
||||||
|
|
||||||
|
def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None:
|
||||||
|
"""Make ``entity`` publish every state of ``source``; ``args`` precede the callback."""
|
||||||
|
publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};"
|
||||||
|
callback = LambdaExpression(
|
||||||
|
[publish], list(self.params), capture="", return_type=cg.void
|
||||||
|
)
|
||||||
|
add(getattr(source, self.callback)(*args, callback))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SubEntities:
|
class SubEntities:
|
||||||
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
|
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
|
||||||
@@ -621,12 +645,6 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
|||||||
[CONF_SENSOR, 1, CONF_POWER_ID].
|
[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):
|
def inherit_property(config):
|
||||||
# Split the property into its path and name
|
# Split the property into its path and name
|
||||||
if not isinstance(property_to_inherit, list):
|
if not isinstance(property_to_inherit, list):
|
||||||
@@ -662,9 +680,171 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
|||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
inherit_property.inherits = (property_to_inherit, parent_id_property, transform)
|
||||||
return inherit_property
|
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]:
|
def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]:
|
||||||
"""Create a validator function to check for duplicate entity names.
|
"""Create a validator function to check for duplicate entity names.
|
||||||
|
|
||||||
|
|||||||
@@ -1809,6 +1809,15 @@ enum class TemperatureUnit : uint8_t {
|
|||||||
KELVIN = 2,
|
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
|
/// @name Utilities
|
||||||
|
|||||||
@@ -483,13 +483,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
|||||||
// Not reached timeout yet, done for this call
|
// Not reached timeout yet, done for this call
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
|
||||||
if (this->is_item_failed_(item)) {
|
|
||||||
LockGuard guard{this->lock_};
|
|
||||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if item is marked for removal
|
// Check if item is marked for removal
|
||||||
// This handles two cases:
|
// This handles two cases:
|
||||||
// 1. Item was marked for removal after cleanup_() but before we got here
|
// 1. Item was marked for removal after cleanup_() but before we got here
|
||||||
@@ -514,6 +507,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||||
|
if (this->is_item_failed_(item)) {
|
||||||
|
LockGuard guard{this->lock_};
|
||||||
|
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||||
{
|
{
|
||||||
SchedulerNameLog name_log;
|
SchedulerNameLog name_log;
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ class FloatLiteral(Literal):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
if math.isnan(self.f):
|
if math.isnan(self.f):
|
||||||
return "NAN"
|
return "NAN"
|
||||||
|
if math.isinf(self.f):
|
||||||
|
return "INFINITY" if self.f > 0 else "-INFINITY"
|
||||||
return f"{self.f}f"
|
return f"{self.f}f"
|
||||||
|
|
||||||
|
|
||||||
@@ -1331,6 +1333,11 @@ class MockObjClass(MockObj):
|
|||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
self._parents += paren._parents
|
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:
|
def inherits_from(self, other: "MockObjClass") -> bool:
|
||||||
if str(self) == str(other):
|
if str(self) == str(other):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from esphome.const import UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN
|
||||||
from esphome.cpp_generator import MockObj
|
from esphome.cpp_generator import MockObj
|
||||||
|
|
||||||
global_ns = MockObj("", "")
|
global_ns = MockObj("", "")
|
||||||
@@ -42,6 +43,12 @@ InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin)
|
|||||||
gpio_ns = esphome_ns.namespace("gpio")
|
gpio_ns = esphome_ns.namespace("gpio")
|
||||||
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
|
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
|
||||||
EntityCategory = esphome_ns.enum("EntityCategory")
|
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")
|
Parented = esphome_ns.class_("Parented")
|
||||||
ESPTime = esphome_ns.struct("ESPTime")
|
ESPTime = esphome_ns.struct("ESPTime")
|
||||||
StringRef = esphome_ns.class_("StringRef")
|
StringRef = esphome_ns.class_("StringRef")
|
||||||
+7
-1
@@ -466,9 +466,15 @@ def receive_exactly(
|
|||||||
|
|
||||||
while len(data) < amount:
|
while len(data) < amount:
|
||||||
try:
|
try:
|
||||||
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
|
chunk = recv_decode(sock, amount - len(data), decode=decode)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
raise OTANetworkError(f"receiving {msg}: {err}") from err
|
raise OTANetworkError(f"receiving {msg}: {err}") from err
|
||||||
|
if not chunk:
|
||||||
|
raise OTANetworkError(
|
||||||
|
f"receiving {msg}: device closed connection after "
|
||||||
|
f"{len(data)} of {amount} bytes"
|
||||||
|
)
|
||||||
|
data += chunk # type: ignore[operator]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+108
-10
@@ -1,8 +1,10 @@
|
|||||||
from collections.abc import Callable, Hashable
|
from collections.abc import Callable, Hashable
|
||||||
|
from contextvars import ContextVar
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
import logging
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
import operator
|
import operator
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -21,17 +23,64 @@ from esphome.const import (
|
|||||||
)
|
)
|
||||||
from esphome.core import CORE, ID
|
from esphome.core import CORE, ID
|
||||||
from esphome.cpp_generator import MockObj, MockObjClass
|
from esphome.cpp_generator import MockObj, MockObjClass
|
||||||
from esphome.schema_extractors import schema_extractor
|
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||||
from esphome.types import ConfigType, SafeExpType
|
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):
|
class PinRegistry(dict):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.pins_used = {}
|
self.pins_used = {}
|
||||||
|
# Entries of pins_used that give way to other uses, keyed by id()
|
||||||
|
self.optional_pins = {}
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.pins_used = {}
|
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):
|
def get_count(self, key, id, number):
|
||||||
"""
|
"""
|
||||||
@@ -86,7 +135,10 @@ class PinRegistry(dict):
|
|||||||
self.pins_used[pin_key] = []
|
self.pins_used[pin_key] = []
|
||||||
# client_id identifies the instance of the providing component
|
# client_id identifies the instance of the providing component
|
||||||
client_id = result.get(key)
|
client_id = result.get(key)
|
||||||
self.pins_used[pin_key].append((path_context.get(), client_id, result))
|
entry = (path_context.get(), client_id, result)
|
||||||
|
self.pins_used[pin_key].append(entry)
|
||||||
|
if _validating_optional.get():
|
||||||
|
self.optional_pins[id(entry)] = entry
|
||||||
# return the validated pin config
|
# return the validated pin config
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -110,23 +162,69 @@ class PinRegistry(dict):
|
|||||||
key = self.get_key(key) if isinstance(key, dict) else key
|
key = self.get_key(key) if isinstance(key, dict) else key
|
||||||
return self[key][0]
|
return self[key][0]
|
||||||
|
|
||||||
def final_validate(self, fconf):
|
def _grouped(self) -> dict[tuple, list[PinUse]]:
|
||||||
"""
|
"""Group pin uses by (schema key, resolved provider id, pin number)."""
|
||||||
Run the final validation for all pins, and check for reuse
|
|
||||||
:param fconf: The full config
|
|
||||||
"""
|
|
||||||
# Regroup by (schema key, resolved provider id, pin number) rather than
|
# Regroup by (schema key, resolved provider id, pin number) rather than
|
||||||
# the validate()-time bucket, so the same physical pin reached via an
|
# the validate()-time bucket, so the same physical pin reached via an
|
||||||
# explicit id and via {address: ...} (or an omitted id) is recognized as
|
# 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.
|
# one reuse group regardless of which syntax was used to reference the hub.
|
||||||
grouped: dict[tuple, list] = {}
|
grouped: dict[tuple, list[PinUse]] = {}
|
||||||
for (key, _, number), pin_list in self.pins_used.items():
|
for (key, _, number), pin_list in self.pins_used.items():
|
||||||
for entry in pin_list:
|
for entry in pin_list:
|
||||||
client_id = entry[1]
|
client_id = entry[1]
|
||||||
provider_id = client_id.id if isinstance(client_id, ID) else client_id
|
provider_id = client_id.id if isinstance(client_id, ID) else client_id
|
||||||
grouped.setdefault((key, provider_id, number), []).append(entry)
|
grouped.setdefault((key, provider_id, number), []).append(entry)
|
||||||
|
return grouped
|
||||||
|
|
||||||
for (key, _, _), pin_list in grouped.items():
|
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():
|
||||||
count = len(pin_list) # number of places same pin used.
|
count = len(pin_list) # number of places same pin used.
|
||||||
final_val_fun = self[key][2] # final validation function
|
final_val_fun = self[key][2] # final validation function
|
||||||
for pin_path, client_id, pin_config in pin_list:
|
for pin_path, client_id, pin_config in pin_list:
|
||||||
|
|||||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user