mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 07:25:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7519502b89 | ||
|
|
c1a3d3cfe6 | ||
|
|
2d73777228 |
No files matched your search
@@ -16,8 +16,6 @@ esphome/components/a01nyub/* @MrSuicideParrot
|
||||
esphome/components/a02yyuw/* @TH-Braemer
|
||||
esphome/components/absolute_humidity/* @DAVe3283
|
||||
esphome/components/ac_dimmer/* @glmnet
|
||||
esphome/components/adafruit_ble/* @omersiar
|
||||
esphome/components/adafruit_ble/ota/* @omersiar
|
||||
esphome/components/adc/* @esphome/core
|
||||
esphome/components/adc128s102/* @DeerMaximum
|
||||
esphome/components/addressable_light/* @justfalter
|
||||
@@ -88,7 +86,6 @@ esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_midi/* @bogdanr
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
|
||||
@@ -348,9 +348,8 @@ class ApplyField:
|
||||
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
|
||||
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
|
||||
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
|
||||
is not the right spelling, or the value depends on another entity's config, such as the unit
|
||||
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
|
||||
``type_``. An absent key emits nothing.
|
||||
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
|
||||
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
|
||||
"""
|
||||
|
||||
conf_key: str | tuple[str, ...]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DOMAIN = "adafruit_ble"
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Host-side Adafruit nRF52 bootloader BLE OTA (Nordic legacy DFU).
|
||||
|
||||
The Adafruit bootloader only serves its DFU service while it is in DFU mode, so
|
||||
an update has two steps: ask the running application to reboot into the
|
||||
bootloader (the trigger service built into the ``adafruit_ble`` OTA component),
|
||||
then push the DFU package to the bootloader itself.
|
||||
|
||||
Reference implementation of the protocol:
|
||||
https://github.com/recrof/nrf_dfu_py/blob/main/dfu_lib.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from bleak.exc import (
|
||||
BleakDBusError,
|
||||
BleakDeviceNotFoundError,
|
||||
BleakError,
|
||||
BleakGATTProtocolError,
|
||||
BleakGATTProtocolErrorCode,
|
||||
)
|
||||
|
||||
from esphome.components.nrf52.ble_logger import MAC_ADDRESS_PATTERN
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.espota2 import ProgressBar
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Nordic legacy DFU service, used by the Adafruit nRF52 bootloader.
|
||||
DFU_SERVICE_UUID = "00001530-1212-efde-1523-785feabcd123"
|
||||
DFU_CONTROL_POINT_UUID = "00001531-1212-efde-1523-785feabcd123"
|
||||
DFU_PACKET_UUID = "00001532-1212-efde-1523-785feabcd123"
|
||||
|
||||
# ESPHome DFU trigger service, exposed by the application (ota_adafruit_ble.cpp).
|
||||
# Kept distinct from the bootloader's DFU service so the two can be told apart.
|
||||
TRIGGER_SERVICE_UUID = "e5b10001-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_CHARACTERISTIC_UUID = "e5b10002-9c3a-4f5d-8a1b-2c3d4e5f6071"
|
||||
TRIGGER_MAGIC = b"DFU"
|
||||
|
||||
_OP_START_DFU = 0x01
|
||||
_OP_INIT_DFU_PARAMS = 0x02
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE = 0x03
|
||||
_OP_VALIDATE = 0x04
|
||||
_OP_ACTIVATE_AND_RESET = 0x05
|
||||
_OP_RESET = 0x06
|
||||
_OP_PACKET_RECEIPT_NOTIF_REQ = 0x08
|
||||
_OP_RESPONSE_CODE = 0x10
|
||||
_OP_PACKET_RECEIPT_NOTIF = 0x11
|
||||
|
||||
_UPLOAD_MODE_APPLICATION = 0x04
|
||||
_RESPONSE_SUCCESS = 0x01
|
||||
_RESPONSE_INVALID_STATE = 0x02
|
||||
_RESPONSE_STATUS_NAMES = {
|
||||
0x02: "invalid state",
|
||||
0x03: "not supported",
|
||||
0x04: "data size exceeds limit",
|
||||
0x05: "CRC error",
|
||||
0x06: "operation failed",
|
||||
}
|
||||
|
||||
# The bootloader runs out of memory when more than 8 packets are unacknowledged.
|
||||
_PRN = 8
|
||||
# The bootloader needs a moment between START_DFU and the image size packet;
|
||||
# sending the size too early leaves the start command unanswered.
|
||||
_START_DELAY = 0.6
|
||||
# A dropped link mid-update is usually transient, so the sequence is retried.
|
||||
_ATTEMPTS = 3
|
||||
_RETRY_DELAY = 3.0
|
||||
# ATT payload cap; the negotiated MTU is normally smaller.
|
||||
_MAX_CHUNK_SIZE = 244
|
||||
# The bootloader rejects image sizes and data packets that are not whole words.
|
||||
_WORD_SIZE = 4
|
||||
_SCAN_TIMEOUT = 10.0
|
||||
_RESPONSE_TIMEOUT = 30.0
|
||||
# Starting the update erases flash and finishing it writes the last pages.
|
||||
_LONG_RESPONSE_TIMEOUT = 60.0
|
||||
_RECEIPT_TIMEOUT = 2.0
|
||||
# Time the bootloader needs to start advertising after the DFU request.
|
||||
_REBOOT_TIMEOUT = 20.0
|
||||
# Without MAC addresses (macOS), the bootloader is the one DFU device that was
|
||||
# not there before the request: the scan that lists those before it, and how
|
||||
# long to keep watching for a second new one after the first appears.
|
||||
_DFU_SNAPSHOT_TIMEOUT = 3.0
|
||||
_NEW_DEVICE_SETTLE = 3.0
|
||||
# macOS reports a device once per scan session, so a fresh session is needed to
|
||||
# see whether the application still advertises.
|
||||
_APP_CHECK_TIMEOUT = 3.0
|
||||
# Error responses to the DFU mode request that pairing the device would fix.
|
||||
_PAIRING_ERRORS = {
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHENTICATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION,
|
||||
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE,
|
||||
}
|
||||
|
||||
BOOTLOADER_HINT = (
|
||||
"A device left in its bootloader by a failed upload advertises at its MAC "
|
||||
"address + 1; pass that address with --device (on macOS, the UUID of the "
|
||||
"DFU device)."
|
||||
)
|
||||
|
||||
|
||||
class _DfuRejectedError(EsphomeError):
|
||||
"""The bootloader answered with a failure status; retrying will not help."""
|
||||
|
||||
|
||||
class _DfuStaleStateError(_DfuRejectedError):
|
||||
"""START found the bootloader mid-update from an interrupted transfer.
|
||||
|
||||
The failed START resets the bootloader, so one more attempt can succeed.
|
||||
"""
|
||||
|
||||
|
||||
def _rejected_error(request_op: int, step: str, status: int) -> _DfuRejectedError:
|
||||
name = _RESPONSE_STATUS_NAMES.get(status, "unknown")
|
||||
error = (
|
||||
_DfuStaleStateError
|
||||
if request_op == _OP_START_DFU and status == _RESPONSE_INVALID_STATE
|
||||
else _DfuRejectedError
|
||||
)
|
||||
return error(
|
||||
f"DFU {step} (opcode {request_op:#04x}) rejected by the bootloader "
|
||||
f"with status {status:#04x} ({name})"
|
||||
)
|
||||
|
||||
|
||||
class _DfuSession:
|
||||
"""Control point notifications and packet receipts of one DFU run."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._responses: asyncio.Queue[tuple[int, int]] = asyncio.Queue()
|
||||
self._receipt = asyncio.Event()
|
||||
self._rejection: tuple[int, int] | None = None
|
||||
self.receipts = 0
|
||||
|
||||
def notification_handler(self, _sender: int, data: bytearray) -> None:
|
||||
if not data:
|
||||
_LOGGER.debug("DFU: ignoring an empty notification")
|
||||
return
|
||||
opcode = data[0]
|
||||
if opcode == _OP_RESPONSE_CODE and len(data) >= 3:
|
||||
self._responses.put_nowait((data[1], data[2]))
|
||||
if data[2] != _RESPONSE_SUCCESS:
|
||||
# Wakes a receipt wait, which no receipt would end after a rejection
|
||||
self._rejection = (data[1], data[2])
|
||||
self._receipt.set()
|
||||
elif opcode == _OP_PACKET_RECEIPT_NOTIF:
|
||||
self.receipts += 1
|
||||
self._receipt.set()
|
||||
else:
|
||||
_LOGGER.debug("DFU: ignoring notification %s", data.hex())
|
||||
|
||||
async def expect_success(
|
||||
self, request_op: int, step: str, timeout: float = _RESPONSE_TIMEOUT
|
||||
) -> None:
|
||||
try:
|
||||
response_op, status = await asyncio.wait_for(self._responses.get(), timeout)
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for the response to opcode {request_op:#04x}"
|
||||
) from err
|
||||
if response_op != request_op:
|
||||
raise EsphomeError(
|
||||
f"DFU got a response for opcode {response_op:#04x}, "
|
||||
f"expected {request_op:#04x}"
|
||||
)
|
||||
if status != _RESPONSE_SUCCESS:
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
def raise_if_rejected(self, step: str) -> None:
|
||||
"""Raise if the bootloader already answered a command with a failure."""
|
||||
if self._rejection is not None:
|
||||
request_op, status = self._rejection
|
||||
raise _rejected_error(request_op, step, status)
|
||||
|
||||
async def wait_for_receipts(self, count: int, step: str) -> None:
|
||||
"""Wait until ``count`` packet receipts have arrived in total."""
|
||||
try:
|
||||
async with asyncio.timeout(_RECEIPT_TIMEOUT):
|
||||
while self.receipts < count:
|
||||
self.raise_if_rejected(step)
|
||||
# Cleared only while the count is short, so no receipt is lost
|
||||
self._receipt.clear()
|
||||
await self._receipt.wait()
|
||||
except TimeoutError as err:
|
||||
raise EsphomeError(
|
||||
f"DFU timed out waiting for packet receipt {count}"
|
||||
) from err
|
||||
|
||||
|
||||
def _service_uuids(advertisement) -> set[str]:
|
||||
return {uuid.lower() for uuid in (advertisement.service_uuids or [])}
|
||||
|
||||
|
||||
def _next_address(address: str) -> str | None:
|
||||
"""The Adafruit bootloader can come up one address above the application."""
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
return None
|
||||
prefix, last = address[:-2], int(address[-2:], 16)
|
||||
return f"{prefix}{((last + 1) & 0xFF):02X}"
|
||||
|
||||
|
||||
async def _dfu_devices() -> set[str]:
|
||||
"""Addresses of the devices advertising the DFU service right now."""
|
||||
found = await BleakScanner.discover(timeout=_DFU_SNAPSHOT_TIMEOUT, return_adv=True)
|
||||
return {
|
||||
device.address
|
||||
for device, advertisement in found.values()
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DfuPackage:
|
||||
"""The application image and init packet of an Adafruit DFU package."""
|
||||
|
||||
name: str
|
||||
image: bytes
|
||||
init_packet: bytes
|
||||
|
||||
|
||||
def load_firmware(firmware: Path) -> DfuPackage:
|
||||
"""Read and check a DFU package, before the device is asked to reboot."""
|
||||
if not firmware.is_file():
|
||||
raise EsphomeError(
|
||||
f"DFU package not found: {firmware}. Compile the firmware first."
|
||||
)
|
||||
try:
|
||||
return _read_package(firmware)
|
||||
except (zipfile.BadZipFile, KeyError, ValueError) as err:
|
||||
raise EsphomeError(f"Invalid DFU package {firmware}: {err}") from err
|
||||
|
||||
|
||||
def _read_package(firmware: Path) -> DfuPackage:
|
||||
with zipfile.ZipFile(firmware) as archive:
|
||||
names = archive.namelist()
|
||||
if "manifest.json" in names:
|
||||
bin_file, dat_file = _manifest_files(
|
||||
firmware, json.loads(archive.read("manifest.json"))
|
||||
)
|
||||
else:
|
||||
bins = [name for name in names if name.endswith(".bin")]
|
||||
dats = [name for name in names if name.endswith(".dat")]
|
||||
if len(bins) > 1 or len(dats) > 1:
|
||||
raise ValueError("more than one .bin or .dat file and no manifest")
|
||||
if not bins or not dats:
|
||||
raise EsphomeError(
|
||||
f"Could not find the application image in {firmware}"
|
||||
)
|
||||
bin_file, dat_file = bins[0], dats[0]
|
||||
image = archive.read(bin_file)
|
||||
init_packet = archive.read(dat_file)
|
||||
if not image:
|
||||
raise EsphomeError(f"Empty application image in {firmware}")
|
||||
if len(image) % _WORD_SIZE:
|
||||
raise EsphomeError(
|
||||
f"The application image in {firmware} is {len(image)} bytes, "
|
||||
f"which the bootloader rejects as it is not a multiple of {_WORD_SIZE}"
|
||||
)
|
||||
return DfuPackage(firmware.name, image, init_packet)
|
||||
|
||||
|
||||
def _manifest_files(firmware: Path, manifest: object) -> tuple[str, str]:
|
||||
"""The image and init packet names of the application in ``manifest``."""
|
||||
if isinstance(manifest, dict):
|
||||
manifest = manifest.get("manifest", manifest)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("manifest.json is not a JSON object")
|
||||
application = manifest.get("application")
|
||||
if application is None:
|
||||
raise EsphomeError(f"{firmware} is not an application update package")
|
||||
if not isinstance(application, dict):
|
||||
raise ValueError("the manifest application entry is not a JSON object")
|
||||
bin_file = application.get("bin_file")
|
||||
dat_file = application.get("dat_file")
|
||||
if not isinstance(bin_file, str) or not isinstance(dat_file, str):
|
||||
raise ValueError("the manifest names no bin_file and dat_file")
|
||||
return bin_file, dat_file
|
||||
|
||||
|
||||
class _DeviceNotFoundError(EsphomeError):
|
||||
"""The device did not answer at the address it was asked for."""
|
||||
|
||||
|
||||
def _raise_if_not_paired(address: str, err: Exception) -> None:
|
||||
if isinstance(err, BleakDBusError) and "NotPermitted" in err.dbus_error:
|
||||
raise EsphomeError(
|
||||
f"Cannot connect to {address}: Make sure the device is paired."
|
||||
) from err
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(address: str) -> AsyncIterator[BleakClient]:
|
||||
client = BleakClient(address, timeout=_SCAN_TIMEOUT)
|
||||
try:
|
||||
await client.connect()
|
||||
except BleakDeviceNotFoundError as err:
|
||||
raise _DeviceNotFoundError(f"Device {address} was not found") from err
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
raise EsphomeError(f"Could not connect to {address}: {err}") from err
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
async def enter_dfu_mode(address: str) -> str:
|
||||
"""Return the address of the bootloader in DFU mode.
|
||||
|
||||
A running application is first asked to reboot into the bootloader. A
|
||||
device that is not found may still be in its bootloader from a failed
|
||||
upload, which can advertise one address above the application.
|
||||
"""
|
||||
request_error: Exception | None = None
|
||||
known_dfu: set[str] | None = None
|
||||
try:
|
||||
async with _connect(address) as client:
|
||||
services = {str(service.uuid).lower() for service in client.services}
|
||||
if DFU_SERVICE_UUID in services:
|
||||
return address
|
||||
if TRIGGER_SERVICE_UUID not in services:
|
||||
raise EsphomeError(
|
||||
f"{address} does not offer an OTA service. Put the device "
|
||||
"into DFU mode and try again."
|
||||
)
|
||||
if not MAC_ADDRESS_PATTERN.match(address):
|
||||
# macOS hides MAC addresses and gives the bootloader its own UUID
|
||||
known_dfu = await _dfu_devices()
|
||||
_LOGGER.info("Requesting DFU mode from %s...", address)
|
||||
try:
|
||||
await client.write_gatt_char(
|
||||
TRIGGER_CHARACTERISTIC_UUID, TRIGGER_MAGIC, response=True
|
||||
)
|
||||
except (BleakError, OSError) as err:
|
||||
_raise_if_not_paired(address, err)
|
||||
# The application reboots as soon as the write is handled, so a
|
||||
# disconnect while waiting for the response is expected. Windows
|
||||
# reports that as a cancelled operation (OSError), not a BleakError.
|
||||
# The error is only reported if the bootloader then does not show
|
||||
# up, except an error response (find_dfu_bootloader).
|
||||
_LOGGER.debug("DFU mode request ended with %s", err)
|
||||
request_error = err
|
||||
except _DeviceNotFoundError:
|
||||
_LOGGER.info("%s was not found, looking for its DFU bootloader", address)
|
||||
return await find_dfu_bootloader(address, request_error, known_dfu)
|
||||
|
||||
|
||||
async def _find_new_dfu_devices(
|
||||
known_dfu: set[str], app_address: str
|
||||
) -> tuple[list[str], bool]:
|
||||
"""Scan for DFU devices that are not in ``known_dfu``.
|
||||
|
||||
Also reports whether ``app_address`` still advertised after one appeared: an application that really restarted
|
||||
into its bootloader stops advertising, so a new DFU device seen while it keeps going belongs to another board.
|
||||
"""
|
||||
known = {address.lower() for address in known_dfu}
|
||||
app = app_address.lower()
|
||||
new: dict[str, str] = {}
|
||||
appeared = asyncio.Event()
|
||||
app_still_advertising = False
|
||||
|
||||
def _on_advertisement(device: BLEDevice, advertisement: AdvertisementData) -> None:
|
||||
nonlocal app_still_advertising
|
||||
key = device.address.lower()
|
||||
if key == app:
|
||||
app_still_advertising = app_still_advertising or appeared.is_set()
|
||||
return
|
||||
if DFU_SERVICE_UUID in _service_uuids(advertisement) and key not in known:
|
||||
new.setdefault(key, device.address)
|
||||
appeared.set()
|
||||
|
||||
async with BleakScanner(detection_callback=_on_advertisement):
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(appeared.wait(), _REBOOT_TIMEOUT)
|
||||
if appeared.is_set():
|
||||
await asyncio.sleep(_NEW_DEVICE_SETTLE)
|
||||
return list(new.values()), app_still_advertising
|
||||
|
||||
|
||||
async def _is_advertising(address: str) -> bool:
|
||||
"""Whether ``address`` shows up in a fresh scan session."""
|
||||
wanted = address.lower()
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
lambda device, _advertisement: device.address.lower() == wanted,
|
||||
timeout=_APP_CHECK_TIMEOUT,
|
||||
)
|
||||
return device is not None
|
||||
|
||||
|
||||
def _raise_if_request_rejected(address: str, request_error: Exception | None) -> None:
|
||||
"""Raise if the application answered the DFU mode request with an error.
|
||||
|
||||
An error response comes from the running application, so it did not
|
||||
restart. A dropped link stays acceptable, as the reboot can look like that.
|
||||
"""
|
||||
if not isinstance(request_error, BleakGATTProtocolError):
|
||||
return
|
||||
message = f"{address} rejected the DFU mode request: {request_error.args[-1]}."
|
||||
if request_error.code in _PAIRING_ERRORS:
|
||||
message += " Make sure the device is paired."
|
||||
raise EsphomeError(message) from request_error
|
||||
|
||||
|
||||
async def find_dfu_bootloader(
|
||||
address: str,
|
||||
request_error: Exception | None = None,
|
||||
known_dfu: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Scan for the bootloader of the device that was asked to enter DFU mode.
|
||||
|
||||
Only ``address`` and the address above it are accepted, so a different
|
||||
device that happens to be in DFU mode nearby is never flashed. With
|
||||
``known_dfu`` (the DFU devices seen before the request, for hosts without
|
||||
MAC addresses) the one DFU device that is new is accepted instead, and only
|
||||
if exactly one appeared and the application no longer advertises. A request
|
||||
the application rejected fails at once, without scanning.
|
||||
"""
|
||||
_raise_if_request_rejected(address, request_error)
|
||||
_LOGGER.info("Scanning for the DFU bootloader...")
|
||||
if known_dfu is not None:
|
||||
new, app_still_advertising = await _find_new_dfu_devices(known_dfu, address)
|
||||
if len(new) == 1 and not app_still_advertising:
|
||||
app_still_advertising = await _is_advertising(address)
|
||||
if not app_still_advertising:
|
||||
return new[0]
|
||||
if app_still_advertising:
|
||||
_LOGGER.warning(
|
||||
"%s kept advertising, so it did not restart into its bootloader",
|
||||
address,
|
||||
)
|
||||
raise _not_found_error(address, request_error, known_dfu | set(new))
|
||||
wanted = {address.lower()}
|
||||
if (next_address := _next_address(address)) is not None:
|
||||
wanted.add(next_address.lower())
|
||||
others: set[str] = set()
|
||||
|
||||
def _is_bootloader(device: BLEDevice, advertisement: AdvertisementData) -> bool:
|
||||
if DFU_SERVICE_UUID not in _service_uuids(advertisement):
|
||||
return False
|
||||
if device.address.lower() in wanted:
|
||||
return True
|
||||
others.add(device.address)
|
||||
return False
|
||||
|
||||
device = await BleakScanner.find_device_by_filter(
|
||||
_is_bootloader, timeout=_REBOOT_TIMEOUT
|
||||
)
|
||||
if device is not None:
|
||||
return device.address
|
||||
raise _not_found_error(address, request_error, others)
|
||||
|
||||
|
||||
def _not_found_error(
|
||||
address: str, request_error: Exception | None, others: set[str]
|
||||
) -> EsphomeError:
|
||||
message = f"The DFU bootloader of {address} was not found."
|
||||
if request_error is not None:
|
||||
message += f" The DFU mode request failed: {request_error}."
|
||||
if others:
|
||||
message += f" Other devices in DFU mode: {', '.join(sorted(others))}."
|
||||
return EsphomeError(f"{message} {BOOTLOADER_HINT}")
|
||||
|
||||
|
||||
async def _write_control(client: BleakClient, op: int, *payload: int) -> None:
|
||||
await client.write_gatt_char(
|
||||
DFU_CONTROL_POINT_UUID, bytes([op, *payload]), response=True
|
||||
)
|
||||
|
||||
|
||||
async def _stream_firmware(
|
||||
client: BleakClient, session: _DfuSession, image: bytes
|
||||
) -> None:
|
||||
# Every data packet must be whole words; the image size is too, so the last
|
||||
# packet is as well.
|
||||
chunk_size = max(20, min(client.mtu_size - 3, _MAX_CHUNK_SIZE))
|
||||
chunk_size -= chunk_size % _WORD_SIZE
|
||||
_LOGGER.debug("DFU uses %d byte chunks for %d bytes", chunk_size, len(image))
|
||||
progress = ProgressBar("Uploading")
|
||||
progress.update(0)
|
||||
try:
|
||||
for packet, offset in enumerate(range(0, len(image), chunk_size), start=1):
|
||||
session.raise_if_rejected("upload")
|
||||
chunk = image[offset : offset + chunk_size]
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, chunk, response=False)
|
||||
progress.update((offset + len(chunk)) / len(image))
|
||||
# The packet that completes the image is answered with the
|
||||
# RECEIVE_FIRMWARE_IMAGE response instead of a receipt.
|
||||
if packet % _PRN == 0 and offset + len(chunk) < len(image):
|
||||
await session.wait_for_receipts(packet // _PRN, "upload")
|
||||
finally:
|
||||
progress.done()
|
||||
|
||||
|
||||
async def _perform_update(
|
||||
client: BleakClient, image: bytes, init_packet: bytes
|
||||
) -> None:
|
||||
session = _DfuSession()
|
||||
await client.start_notify(DFU_CONTROL_POINT_UUID, session.notification_handler)
|
||||
try:
|
||||
_LOGGER.debug("DFU: starting the update")
|
||||
await _write_control(client, _OP_START_DFU, _UPLOAD_MODE_APPLICATION)
|
||||
await asyncio.sleep(_START_DELAY)
|
||||
await client.write_gatt_char(
|
||||
DFU_PACKET_UUID, struct.pack("<III", 0, 0, len(image)), response=False
|
||||
)
|
||||
try:
|
||||
await session.expect_success(
|
||||
_OP_START_DFU, "start", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
except EsphomeError:
|
||||
with suppress(BleakError, OSError):
|
||||
await _write_control(client, _OP_RESET)
|
||||
raise
|
||||
|
||||
_LOGGER.debug("DFU: sending the init packet")
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x00)
|
||||
await client.write_gatt_char(DFU_PACKET_UUID, init_packet, response=False)
|
||||
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x01)
|
||||
await session.expect_success(_OP_INIT_DFU_PARAMS, "init packet")
|
||||
|
||||
await _write_control(
|
||||
client, _OP_PACKET_RECEIPT_NOTIF_REQ, *struct.pack("<H", _PRN)
|
||||
)
|
||||
_LOGGER.debug("DFU: streaming the image")
|
||||
await _write_control(client, _OP_RECEIVE_FIRMWARE_IMAGE)
|
||||
await _stream_firmware(client, session, image)
|
||||
await session.expect_success(
|
||||
_OP_RECEIVE_FIRMWARE_IMAGE, "upload", timeout=_LONG_RESPONSE_TIMEOUT
|
||||
)
|
||||
|
||||
await _write_control(client, _OP_VALIDATE)
|
||||
await session.expect_success(_OP_VALIDATE, "validation")
|
||||
|
||||
# The bootloader resets as soon as it handles this, so the link drops
|
||||
# before the write can be acknowledged (an OSError on Windows). An error
|
||||
# response means it answered and refused.
|
||||
try:
|
||||
await _write_control(client, _OP_ACTIVATE_AND_RESET)
|
||||
except BleakGATTProtocolError as err:
|
||||
raise _DfuRejectedError(
|
||||
f"DFU activation rejected by the bootloader: {err}"
|
||||
) from err
|
||||
except (BleakError, OSError) as err:
|
||||
_LOGGER.debug("DFU activation ended with %s", err)
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
await client.stop_notify(DFU_CONTROL_POINT_UUID)
|
||||
|
||||
|
||||
async def _dfu_attempt(address: str, image: bytes, init_packet: bytes) -> None:
|
||||
"""Run the whole DFU sequence once."""
|
||||
async with _connect(address) as client:
|
||||
try:
|
||||
await _perform_update(client, image, init_packet)
|
||||
except (BleakError, OSError) as err:
|
||||
raise EsphomeError(f"DFU failed: {err}") from err
|
||||
|
||||
|
||||
async def dfu_upload(address: str, package: DfuPackage) -> None:
|
||||
"""Flash ``package`` (from ``load_firmware``) to ``address``."""
|
||||
stale_state_retried = False
|
||||
for attempt in range(1, _ATTEMPTS + 1):
|
||||
_LOGGER.info(
|
||||
"Uploading %s to %s (%d bytes)%s...",
|
||||
package.name,
|
||||
address,
|
||||
len(package.image),
|
||||
f", attempt {attempt}/{_ATTEMPTS}" if attempt > 1 else "",
|
||||
)
|
||||
try:
|
||||
await _dfu_attempt(address, package.image, package.init_packet)
|
||||
except _DfuStaleStateError as err:
|
||||
if stale_state_retried or attempt == _ATTEMPTS:
|
||||
raise
|
||||
stale_state_retried = True
|
||||
_LOGGER.warning(
|
||||
"%s; it was left mid-update and has been reset, retrying", err
|
||||
)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
except _DfuRejectedError:
|
||||
raise
|
||||
except EsphomeError as err:
|
||||
if attempt == _ATTEMPTS:
|
||||
raise
|
||||
_LOGGER.warning("DFU attempt %d/%d failed: %s", attempt, _ATTEMPTS, err)
|
||||
await asyncio.sleep(_RETRY_DELAY)
|
||||
else:
|
||||
_LOGGER.info("DFU finished; the device restarts into the new firmware")
|
||||
return
|
||||
@@ -1,83 +0,0 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
|
||||
from esphome.components.ota import (
|
||||
BASE_OTA_SCHEMA,
|
||||
CONF_ON_ABORT,
|
||||
CONF_ON_END,
|
||||
CONF_ON_PROGRESS,
|
||||
OTAComponent,
|
||||
ota_to_code,
|
||||
)
|
||||
from esphome.components.zephyr import zephyr_data
|
||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||
from esphome.components.zephyr_ble_server import (
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
|
||||
from esphome.core import CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@omersiar"]
|
||||
DEPENDENCIES = ["zephyr", "zephyr_ble_server"]
|
||||
|
||||
AdafruitBLEOTAComponent = cg.esphome_ns.namespace("adafruit_ble").class_(
|
||||
"OTAComponent", OTAComponent
|
||||
)
|
||||
|
||||
# The device reboots into the bootloader before the transfer starts, so only
|
||||
# on_begin and on_state_change can fire.
|
||||
_UNSUPPORTED_TRIGGERS = (CONF_ON_PROGRESS, CONF_ON_END, CONF_ON_ERROR, CONF_ON_ABORT)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BASE_OTA_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AdafruitBLEOTAComponent),
|
||||
**{
|
||||
cv.Optional(key): cv.invalid(
|
||||
f"'{key}' never fires with Adafruit BLE OTA: the bootloader "
|
||||
"runs the update after the device restarts into it"
|
||||
)
|
||||
for key in _UNSUPPORTED_TRIGGERS
|
||||
},
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_with_framework(Framework.ZEPHYR),
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# Every Adafruit bootloader variant is named adafruit*
|
||||
bootloader = zephyr_data().get(KEY_BOOTLOADER, "")
|
||||
if not bootloader.startswith(BOOTLOADER_ADAFRUIT):
|
||||
raise cv.Invalid(
|
||||
f"Adafruit BLE OTA requires an Adafruit bootloader, but '{bootloader}' "
|
||||
"is configured. Set 'bootloader:' to an Adafruit one."
|
||||
)
|
||||
server = fv.full_config.get().get(ZEPHYR_BLE_SERVER_DOMAIN) or {}
|
||||
if CONF_ON_NUMERIC_COMPARISON_REQUEST not in server:
|
||||
_LOGGER.warning(
|
||||
"Adafruit BLE OTA without pairing lets any nearby device restart this "
|
||||
"one into the bootloader; add '%s' to '%s' to require pairing",
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST,
|
||||
ZEPHYR_BLE_SERVER_DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# The Adafruit bootloader serves the update itself; the component provides
|
||||
# the BLE service that asks the application to restart into it.
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
# Registered before ota_to_code waits past safe_mode, so safe mode keeps the trigger
|
||||
await cg.register_component(var, config)
|
||||
await ota_to_code(var, config)
|
||||
@@ -1,73 +0,0 @@
|
||||
#include "ota_adafruit_ble.h"
|
||||
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <hal/nrf_power.h>
|
||||
#include <zephyr/bluetooth/gatt.h>
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "adafruit_ble");
|
||||
|
||||
// Written by the upload tool (dfu.py) to ask for a restart into the bootloader,
|
||||
// which then serves the DFU transfer. The bootloader only advertises its own DFU
|
||||
// service while it is in DFU mode, so this service is what identifies the
|
||||
// running application.
|
||||
static constexpr uint8_t DFU_TRIGGER_MAGIC[] = {'D', 'F', 'U'};
|
||||
// GPREGRET value the Adafruit nRF52 bootloader reads to enter OTA (BLE DFU) mode
|
||||
static constexpr uint8_t DFU_GPREGRET_OTA = 0xA8;
|
||||
|
||||
// Matches the MCUmgr BLE transport default: a paired (authenticated) link once pairing is enabled
|
||||
#ifdef CONFIG_BT_SMP
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE_AUTHEN;
|
||||
#else
|
||||
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE;
|
||||
#endif
|
||||
|
||||
static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ssize_t dfu_trigger_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len,
|
||||
uint16_t offset, uint8_t flags) {
|
||||
if (offset != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
|
||||
}
|
||||
if (len != sizeof(DFU_TRIGGER_MAGIC) || memcmp(buf, DFU_TRIGGER_MAGIC, sizeof(DFU_TRIGGER_MAGIC)) != 0) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
|
||||
}
|
||||
global_ota_component->request_dfu_mode();
|
||||
return len;
|
||||
}
|
||||
|
||||
// Registered by bt_enable(), which zephyr_ble_server calls during its setup.
|
||||
BT_GATT_SERVICE_DEFINE( // NOLINT
|
||||
dfu_trigger_svc,
|
||||
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10001, 0x9c3a, 0x4f5d, 0x8a1b,
|
||||
0x2c3d4e5f6071))),
|
||||
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10002, 0x9c3a, 0x4f5d, 0x8a1b, 0x2c3d4e5f6071)),
|
||||
BT_GATT_CHRC_WRITE, DFU_TRIGGER_PERM, nullptr, dfu_trigger_write, nullptr));
|
||||
|
||||
OTAComponent::OTAComponent() { global_ota_component = this; }
|
||||
|
||||
void OTAComponent::request_dfu_mode() {
|
||||
// Restart from the main loop, so the OTA listeners run there and the write is answered first
|
||||
this->defer([this]() {
|
||||
ESP_LOGI(TAG, "DFU mode requested, restarting into the bootloader");
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address
|
||||
NRF_POWER->GPREGRET = DFU_GPREGRET_OTA;
|
||||
App.reboot();
|
||||
});
|
||||
}
|
||||
|
||||
void OTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Over-The-Air Updates:\n"
|
||||
" bootloader: Adafruit nRF52 (BLE DFU)");
|
||||
}
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -1,19 +0,0 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::adafruit_ble {
|
||||
|
||||
// The update itself is served by the Adafruit bootloader; this component only
|
||||
// hosts the BLE trigger service defined in ota_adafruit_ble.cpp.
|
||||
class OTAComponent : public ota::OTAComponent {
|
||||
public:
|
||||
OTAComponent();
|
||||
void dump_config() override;
|
||||
// Called from the Bluetooth thread when the trigger is written
|
||||
void request_dfu_mode();
|
||||
};
|
||||
|
||||
} // namespace esphome::adafruit_ble
|
||||
#endif
|
||||
@@ -1,213 +0,0 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHANNEL,
|
||||
CONF_DATA,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_ON_MESSAGE,
|
||||
CONF_PAYLOAD,
|
||||
CONF_VALUE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@bogdanr"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "ble_midi"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_CONTROL_NUMBER = "control_number"
|
||||
CONF_MAX_SYSEX_SIZE = "max_sysex_size"
|
||||
CONF_NOTE = "note"
|
||||
CONF_ON_CONTROL_CHANGE = "on_control_change"
|
||||
CONF_ON_NOTE_OFF = "on_note_off"
|
||||
CONF_ON_NOTE_ON = "on_note_on"
|
||||
CONF_ON_PITCH_BEND = "on_pitch_bend"
|
||||
CONF_ON_PROGRAM_CHANGE = "on_program_change"
|
||||
CONF_ON_SYSEX = "on_sysex"
|
||||
CONF_PAIR = "pair"
|
||||
CONF_PROGRAM = "program"
|
||||
CONF_SYSEX_BUFFER_ID = "sysex_buffer_id"
|
||||
CONF_VELOCITY = "velocity"
|
||||
|
||||
ble_midi_ns = cg.esphome_ns.namespace("ble_midi")
|
||||
BLEMidi = ble_midi_ns.class_("BLEMidi", cg.Component, ble_client.BLEClientNode)
|
||||
# The message bytes as a view: data.size() and data[i] work, and nothing is copied
|
||||
ByteSpan = cg.std_span.template(cg.uint8.operator("const"))
|
||||
|
||||
# MIDI data bytes are 7-bit and channels are 4-bit, matching what goes on the wire and what the triggers report.
|
||||
# Channel 0 is the channel that MIDI hardware usually labels "1".
|
||||
midi_data_byte = cv.int_range(min=0, max=127)
|
||||
midi_channel = cv.int_range(min=0, max=15)
|
||||
|
||||
_NOTE_ARGS = [
|
||||
(cg.uint8, CONF_NOTE),
|
||||
(cg.uint8, CONF_VELOCITY),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
]
|
||||
_BYTES_ARGS = [(ByteSpan, CONF_DATA)]
|
||||
# data points into a notification or a reused buffer, so these automations cannot wait
|
||||
_BYTES_TRIGGERS = (CONF_ON_SYSEX, CONF_ON_MESSAGE)
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_ON, "add_on_note_on_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_NOTE_OFF, "add_on_note_off_callback", _NOTE_ARGS
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_CONTROL_CHANGE,
|
||||
"add_on_control_change_callback",
|
||||
[
|
||||
(cg.uint8, CONF_CONTROL_NUMBER),
|
||||
(cg.uint8, CONF_VALUE),
|
||||
(cg.uint8, CONF_CHANNEL),
|
||||
],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PROGRAM_CHANGE,
|
||||
"add_on_program_change_callback",
|
||||
[(cg.uint8, CONF_PROGRAM), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PITCH_BEND,
|
||||
"add_on_pitch_bend_callback",
|
||||
[(cg.int16, CONF_VALUE), (cg.uint8, CONF_CHANNEL)],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_SYSEX, "add_on_sysex_callback", _BYTES_ARGS),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_MESSAGE, "add_on_message_callback", _BYTES_ARGS
|
||||
),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEMidi),
|
||||
cv.GenerateID(CONF_SYSEX_BUFFER_ID): cv.declare_id(cg.uint8),
|
||||
cv.Optional(CONF_MAX_SYSEX_SIZE, default=256): cv.int_range(
|
||||
min=8, max=2048
|
||||
),
|
||||
cv.Optional(CONF_PAIR, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(entry.conf_key): (
|
||||
automation.validate_synchronous_automation(
|
||||
f"ble_midi {entry.conf_key}", "data"
|
||||
)
|
||||
if entry.conf_key in _BYTES_TRIGGERS
|
||||
else automation.validate_automation({})
|
||||
)
|
||||
for entry in _CALLBACK_AUTOMATIONS
|
||||
},
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
size = config[CONF_MAX_SYSEX_SIZE]
|
||||
# SysEx messages are reassembled in a static array, so nothing is allocated at run time
|
||||
buf = config[CONF_SYSEX_BUFFER_ID].id
|
||||
cg.add_global(cg.RawStatement(f"static uint8_t {buf}[{size}];"))
|
||||
var = await ble_client.new_ble_node_component(
|
||||
config, cg.RawExpression(buf), size, config[CONF_PAIR]
|
||||
)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
def _action_schema(keys: dict) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
**keys,
|
||||
cv.Optional(CONF_CHANNEL, default=0): cv.templatable(midi_channel),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_NOTE_ACTION_SCHEMA = _action_schema(
|
||||
{
|
||||
cv.Required(CONF_NOTE): cv.templatable(midi_data_byte),
|
||||
cv.Optional(CONF_VELOCITY, default=64): cv.templatable(midi_data_byte),
|
||||
}
|
||||
)
|
||||
|
||||
# Wider than the wire values so the C++ range check sees a lambda's result before it is narrowed
|
||||
_CHANNEL = (CONF_CHANNEL, cg.int32)
|
||||
_NOTE_CALL_ARGS = ((CONF_NOTE, cg.int32), (CONF_VELOCITY, cg.int32), _CHANNEL)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_on",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_on({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_note_off",
|
||||
_NOTE_ACTION_SCHEMA,
|
||||
automation.ApplyCall("send_note_off({}, {}, {})", _NOTE_CALL_ARGS),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_control_change",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_CONTROL_NUMBER): cv.templatable(midi_data_byte),
|
||||
cv.Required(CONF_VALUE): cv.templatable(midi_data_byte),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall(
|
||||
"send_control_change({}, {}, {})",
|
||||
((CONF_CONTROL_NUMBER, cg.int32), (CONF_VALUE, cg.int32), _CHANNEL),
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_program_change",
|
||||
_action_schema({cv.Required(CONF_PROGRAM): cv.templatable(midi_data_byte)}),
|
||||
automation.ApplyCall(
|
||||
"send_program_change({}, {})", ((CONF_PROGRAM, cg.int32), _CHANNEL)
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_pitch_bend",
|
||||
_action_schema(
|
||||
{
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.int_range(min=-8192, max=8191)),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall("send_pitch_bend({}, {})", ((CONF_VALUE, cg.int32), _CHANNEL)),
|
||||
)
|
||||
|
||||
|
||||
def _bytes_action_schema(key: str) -> cv.Schema:
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BLEMidi),
|
||||
cv.Required(key): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_sysex",
|
||||
_bytes_action_schema(CONF_PAYLOAD),
|
||||
automation.ApplyBytes(CONF_PAYLOAD, "send_sysex({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ble_midi.send_raw",
|
||||
_bytes_action_schema(CONF_DATA),
|
||||
automation.ApplyBytes(CONF_DATA, "send_raw({})"),
|
||||
)
|
||||
|
||||
automation.register_apply_condition(
|
||||
"ble_midi.connected", cv.Schema({cv.GenerateID(): cv.use_id(BLEMidi)}), "is_ready()"
|
||||
)
|
||||
@@ -1,310 +0,0 @@
|
||||
#include "ble_midi.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_midi");
|
||||
|
||||
// Standard BLE-MIDI service and characteristic, as defined by the BLE-MIDI
|
||||
// specification.
|
||||
static constexpr const char *SERVICE_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700";
|
||||
static constexpr const char *CHARACTERISTIC_UUID = "7772E5DB-3868-4112-A1A9-F2669D106BF3";
|
||||
|
||||
// MIDI status bytes for the messages this component can transmit.
|
||||
static constexpr uint8_t STATUS_NOTE_OFF = 0x80;
|
||||
static constexpr uint8_t STATUS_NOTE_ON = 0x90;
|
||||
static constexpr uint8_t STATUS_CONTROL_CHANGE = 0xB0;
|
||||
static constexpr uint8_t STATUS_PROGRAM_CHANGE = 0xC0;
|
||||
static constexpr uint8_t STATUS_PITCH_BEND = 0xE0;
|
||||
|
||||
// Number of received bytes included in verbose packet logs.
|
||||
static constexpr size_t LOG_MAX_BYTES = 32;
|
||||
|
||||
void BLEMidi::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE MIDI:\n"
|
||||
" MAC address: %s\n"
|
||||
" Maximum SysEx size: %u\n"
|
||||
" Pair on connect: %s",
|
||||
this->parent()->address_str(), this->parser_.sysex_capacity(), YESNO(this->pair_));
|
||||
}
|
||||
|
||||
void BLEMidi::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (param->open.status != ESP_GATT_OK)
|
||||
break;
|
||||
// A warning left by an earlier session no longer applies
|
||||
this->status_clear_warning();
|
||||
this->parser_.reset();
|
||||
if (this->pair_) {
|
||||
esp_err_t err = this->parent()->pair();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Pairing failed, error=%d", err);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CFG_MTU_EVT: {
|
||||
if (param->cfg_mtu.status == ESP_GATT_OK)
|
||||
this->mtu_ = param->cfg_mtu.mtu;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
this->parser_.reset();
|
||||
this->characteristic_handle_ = 0;
|
||||
this->cccd_handle_ = 0;
|
||||
this->mtu_ = 23;
|
||||
// Both events can arrive for the same disconnect, so the automation runs
|
||||
// only for the one that follows an established MIDI connection.
|
||||
const bool was_ready = this->notify_registered_;
|
||||
this->notify_registered_ = false;
|
||||
this->node_state = espbt::ClientState::IDLE;
|
||||
if (was_ready)
|
||||
this->disconnect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
if (param->search_cmpl.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Service discovery failed, status=%d", param->search_cmpl.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
auto *characteristic = this->parent()->get_characteristic(espbt::ESPBTUUID::from_raw(SERVICE_UUID),
|
||||
espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID));
|
||||
// Reconnecting would not help a device without them, so the link is left as it is
|
||||
if (characteristic == nullptr) {
|
||||
this->status_set_warning(LOG_STR("No BLE MIDI characteristic found on this device"));
|
||||
break;
|
||||
}
|
||||
auto *cccd = characteristic->get_descriptor(ESP_GATT_UUID_CHAR_CLIENT_CONFIG);
|
||||
if (cccd == nullptr) {
|
||||
this->status_set_warning(LOG_STR("BLE MIDI characteristic has no notification descriptor"));
|
||||
break;
|
||||
}
|
||||
this->characteristic_handle_ = characteristic->handle;
|
||||
this->cccd_handle_ = cccd->handle;
|
||||
this->write_without_response_ = (characteristic->properties & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) != 0;
|
||||
esp_err_t err = this->parent()->register_for_notify(characteristic->handle);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register_for_notify failed, error=%d", err);
|
||||
this->fail_session_();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
if (param->reg_for_notify.handle != this->characteristic_handle_)
|
||||
break;
|
||||
if (param->reg_for_notify.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Registering for notifications failed, status=%d", param->reg_for_notify.status);
|
||||
this->fail_session_();
|
||||
}
|
||||
// On success the parent writes the descriptor; the device has accepted it once WRITE_DESCR_EVT reports OK
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_DESCR_EVT: {
|
||||
if (this->cccd_handle_ == 0 || param->write.handle != this->cccd_handle_ || this->notify_registered_)
|
||||
break;
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Device refused to enable notifications, status=%d", param->write.status);
|
||||
this->fail_session_();
|
||||
break;
|
||||
}
|
||||
this->notify_registered_ = true;
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
ESP_LOGI(TAG, "MIDI notifications enabled");
|
||||
// The device can only be sent to once notifications are enabled, so this
|
||||
// is the point where on_connect is useful.
|
||||
this->connect_callback_.call();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT: {
|
||||
// A write with response reports its outcome here; one without response reports nothing
|
||||
if (param->write.handle == this->characteristic_handle_ && param->write.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "Write rejected, status=%d", param->write.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->characteristic_handle_ || param->notify.value_len == 0)
|
||||
break;
|
||||
// format_hex_to() truncates to the buffer size, so long packets are logged
|
||||
// only in part.
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Received %u bytes: %s", param->notify.value_len,
|
||||
format_hex_to(hex, param->notify.value, param->notify.value_len));
|
||||
this->parser_.feed(param->notify.value, param->notify.value_len, BLEMidi::dispatch_message, this);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BLEMidi::fail_session_() {
|
||||
this->status_set_warning();
|
||||
// ble_client reconnects, which runs the setup again
|
||||
this->parent()->disconnect();
|
||||
}
|
||||
|
||||
void BLEMidi::handle_message_(const MidiMessage &message) {
|
||||
ESP_LOGV(
|
||||
TAG, "Decoded %s",
|
||||
LOG_STR_ARG(MessageTypeNames::get_log_str(static_cast<uint8_t>(message.type), MessageTypeNames::LAST_INDEX)));
|
||||
// Only decoded messages reach on_message
|
||||
if (message.type == MessageType::SYSEX_INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "SysEx of %u bytes dropped: cut short, or longer than max_sysex_size (%u)", message.length,
|
||||
this->parser_.sysex_capacity());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::INCOMPLETE) {
|
||||
ESP_LOGW(TAG, "MIDI message 0x%02X dropped: cut short by another status byte", message.status());
|
||||
return;
|
||||
}
|
||||
if (message.type == MessageType::UNKNOWN) {
|
||||
ESP_LOGW(TAG, "Undecodable MIDI byte 0x%02X", message.status());
|
||||
return;
|
||||
}
|
||||
this->message_callback_.call(ByteSpan(message.data, message.length));
|
||||
|
||||
switch (message.type) {
|
||||
case MessageType::NOTE_ON:
|
||||
this->note_on_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::NOTE_OFF:
|
||||
this->note_off_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::CONTROL_CHANGE:
|
||||
this->control_change_callback_.call(message.data1(), message.data2(), message.channel());
|
||||
break;
|
||||
case MessageType::PROGRAM_CHANGE:
|
||||
this->program_change_callback_.call(message.data1(), message.channel());
|
||||
break;
|
||||
case MessageType::PITCH_BEND:
|
||||
this->pitch_bend_callback_.call(message.pitch_bend(), message.channel());
|
||||
break;
|
||||
case MessageType::SYSEX:
|
||||
// The payload without the 0xF0/0xF7 wrappers; a complete SysEx always has both
|
||||
this->sysex_callback_.call(ByteSpan(message.data + 1, message.length - 2));
|
||||
break;
|
||||
default:
|
||||
// Polyphonic and channel aftertouch, system common and real-time messages
|
||||
// are only reported through on_message.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEMidi::write_packet_(uint8_t *packet, size_t midi_length) {
|
||||
if (!this->is_ready()) {
|
||||
ESP_LOGW(TAG, "Cannot send: not connected");
|
||||
return false;
|
||||
}
|
||||
// Every BLE-MIDI packet has to fit one ATT packet
|
||||
const size_t packet_length = midi_length + 2;
|
||||
const size_t limit = std::min<size_t>(MAX_TX_MIDI_BYTES + 2, this->mtu_ - 3);
|
||||
if (packet_length > limit) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes exceeds the %u byte limit", static_cast<unsigned>(midi_length),
|
||||
static_cast<unsigned>(limit - 2));
|
||||
return false;
|
||||
}
|
||||
packet[0] = PACKET_HEADER;
|
||||
packet[1] = PACKET_TIMESTAMP;
|
||||
esp_err_t err = esp_ble_gattc_write_char(
|
||||
this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->characteristic_handle_, packet_length,
|
||||
packet, this->write_without_response_ ? ESP_GATT_WRITE_TYPE_NO_RSP : ESP_GATT_WRITE_TYPE_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Write failed, error=%d", err);
|
||||
return false;
|
||||
}
|
||||
char hex[format_hex_size(LOG_MAX_BYTES)];
|
||||
ESP_LOGV(TAG, "Sent %u bytes: %s", static_cast<unsigned>(packet_length), format_hex_to(hex, packet, packet_length));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BLEMidi::write_midi_(const uint8_t *midi_bytes, size_t length) {
|
||||
if (length == 0 || length > MAX_TX_MIDI_BYTES) {
|
||||
ESP_LOGW(TAG, "Cannot send: message of %u bytes, the limit is %u", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return false;
|
||||
}
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
std::memcpy(packet + 2, midi_bytes, length);
|
||||
return this->write_packet_(packet, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length) {
|
||||
// Validation covers constants only; a lambda can return any value, and masking it would send a different message.
|
||||
// The values arrive unnarrowed so a negative or wrapped one is caught here (the unsigned compare covers negatives).
|
||||
if (static_cast<uint32_t>(channel) > 0x0F || static_cast<uint32_t>(data1) > 0x7F ||
|
||||
static_cast<uint32_t>(data2) > 0x7F) {
|
||||
ESP_LOGW(TAG, "Cannot send: channel %" PRId32 " or value %" PRId32 "/%" PRId32 " out of range", channel, data1,
|
||||
data2);
|
||||
return;
|
||||
}
|
||||
const uint8_t bytes[3] = {static_cast<uint8_t>(status | channel), static_cast<uint8_t>(data1),
|
||||
static_cast<uint8_t>(data2)};
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_on(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_ON, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_note_off(int32_t note, int32_t velocity, int32_t channel) {
|
||||
this->send_channel_(STATUS_NOTE_OFF, channel, note, velocity, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_control_change(int32_t control_number, int32_t value, int32_t channel) {
|
||||
this->send_channel_(STATUS_CONTROL_CHANGE, channel, control_number, value, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_program_change(int32_t program, int32_t channel) {
|
||||
this->send_channel_(STATUS_PROGRAM_CHANGE, channel, program, 0, 2);
|
||||
}
|
||||
|
||||
void BLEMidi::send_pitch_bend(int32_t value, int32_t channel) {
|
||||
if (value < -8192 || value > 8191) {
|
||||
ESP_LOGW(TAG, "Cannot send: pitch bend %" PRId32 " out of range", value);
|
||||
return;
|
||||
}
|
||||
const uint16_t unsigned_value = static_cast<uint16_t>(value + 8192);
|
||||
this->send_channel_(STATUS_PITCH_BEND, channel, unsigned_value & 0x7F, unsigned_value >> 7, 3);
|
||||
}
|
||||
|
||||
void BLEMidi::send_sysex(const uint8_t *payload, size_t length) {
|
||||
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
|
||||
const size_t framed = frame_sysex(payload, length, packet + 2, MAX_TX_MIDI_BYTES);
|
||||
if (framed == 0) {
|
||||
ESP_LOGW(TAG, "Cannot send: SysEx of %u bytes is empty or exceeds the %u byte limit", static_cast<unsigned>(length),
|
||||
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
|
||||
return;
|
||||
}
|
||||
this->write_packet_(packet, framed);
|
||||
}
|
||||
|
||||
void BLEMidi::send_raw(const uint8_t *bytes, size_t length) {
|
||||
// The packet carries one timestamp, in front of the first byte, so the payload has to be a single complete
|
||||
// message with its status byte first; a SysEx goes through send_sysex
|
||||
bool one_message = length > 0 && (bytes[0] & 0x80) != 0 && bytes[0] != STATUS_SYSEX_START &&
|
||||
bytes[0] != STATUS_SYSEX_END && length == 1u + MidiParser::expected_data_bytes(bytes[0]);
|
||||
for (size_t i = 1; one_message && i < length; i++) {
|
||||
one_message = (bytes[i] & 0x80) == 0;
|
||||
}
|
||||
if (!one_message) {
|
||||
ESP_LOGW(TAG, "Cannot send: raw payload must be one message, a status byte followed by data bytes");
|
||||
return;
|
||||
}
|
||||
this->write_midi_(bytes, length);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -1,138 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
/// The bytes of a received message, valid for the duration of the automation.
|
||||
using ByteSpan = std::span<const uint8_t>;
|
||||
|
||||
/// Longest MIDI message that can be transmitted in a single BLE-MIDI packet, header and timestamp excluded.
|
||||
static constexpr size_t MAX_TX_MIDI_BYTES = 128;
|
||||
|
||||
/// Client for devices implementing the standard BLE-MIDI service, e.g. wireless
|
||||
/// MIDI keyboards and pad controllers. Incoming messages are exposed as
|
||||
/// automation triggers; outgoing messages are sent with the ble_midi.send_*
|
||||
/// actions.
|
||||
///
|
||||
/// Specification: "Specification for MIDI over Bluetooth Low Energy
|
||||
/// (BLE-MIDI)", MIDI Association, document M1-2017-11-01.
|
||||
class BLEMidi final : public Component, public ble_client::BLEClientNode {
|
||||
public:
|
||||
/// sysex_buffer holds sysex_capacity bytes; longer SysEx messages are truncated.
|
||||
BLEMidi(uint8_t *sysex_buffer, uint16_t sysex_capacity, bool pair)
|
||||
: parser_(sysex_buffer, sysex_capacity), pair_(pair) {}
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
|
||||
/// True once the MIDI characteristic has been discovered and notifications
|
||||
/// are enabled.
|
||||
bool is_ready() const { return this->notify_registered_; }
|
||||
|
||||
// Channel is 0-15, note/velocity/controller/value/program are 0-127; anything else is refused with a warning.
|
||||
void send_note_on(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_note_off(int32_t note, int32_t velocity, int32_t channel);
|
||||
void send_control_change(int32_t control_number, int32_t value, int32_t channel);
|
||||
void send_program_change(int32_t program, int32_t channel);
|
||||
/// Pitch bend value is -8192 to 8191, centered on zero.
|
||||
void send_pitch_bend(int32_t value, int32_t channel);
|
||||
/// Send a System Exclusive message. The 0xF0/0xF7 wrappers are added if the
|
||||
/// payload does not already carry them.
|
||||
void send_sysex(const uint8_t *payload, size_t length);
|
||||
/// Send raw MIDI bytes (status byte followed by its data bytes).
|
||||
void send_raw(const uint8_t *bytes, size_t length);
|
||||
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel)
|
||||
template<typename F> void add_on_note_on_callback(F &&callback) {
|
||||
this->note_on_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (note, velocity, channel); a Note On with velocity zero is reported here as well
|
||||
template<typename F> void add_on_note_off_callback(F &&callback) {
|
||||
this->note_off_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (control_number, value, channel)
|
||||
template<typename F> void add_on_control_change_callback(F &&callback) {
|
||||
this->control_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (program, channel)
|
||||
template<typename F> void add_on_program_change_callback(F &&callback) {
|
||||
this->program_change_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// (value, channel), value being -8192 to 8191
|
||||
template<typename F> void add_on_pitch_bend_callback(F &&callback) {
|
||||
this->pitch_bend_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The SysEx payload without the 0xF0/0xF7 wrappers
|
||||
template<typename F> void add_on_sysex_callback(F &&callback) {
|
||||
this->sysex_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// The raw bytes of every decoded message
|
||||
template<typename F> void add_on_message_callback(F &&callback) {
|
||||
this->message_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
static void dispatch_message(void *ctx, const MidiMessage &message) {
|
||||
static_cast<BLEMidi *>(ctx)->handle_message_(message);
|
||||
}
|
||||
void handle_message_(const MidiMessage &message);
|
||||
/// Wrap MIDI bytes in a BLE-MIDI packet and write them to the MIDI characteristic.
|
||||
bool write_midi_(const uint8_t *midi_bytes, size_t length);
|
||||
/// Write a packet whose MIDI bytes already sit at packet + 2; the header and timestamp are filled in.
|
||||
bool write_packet_(uint8_t *packet, size_t midi_length);
|
||||
/// Channel-voice message: the status byte takes the channel; out-of-range values are refused.
|
||||
void send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length);
|
||||
/// Set a warning and drop the link so that ble_client reconnects and the setup runs again.
|
||||
void fail_session_();
|
||||
|
||||
MidiParser parser_;
|
||||
LazyCallbackManager<void()> connect_callback_;
|
||||
LazyCallbackManager<void()> disconnect_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_on_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_off_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> control_change_callback_;
|
||||
LazyCallbackManager<void(uint8_t, uint8_t)> program_change_callback_;
|
||||
LazyCallbackManager<void(int16_t, uint8_t)> pitch_bend_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> sysex_callback_;
|
||||
LazyCallbackManager<void(ByteSpan)> message_callback_;
|
||||
uint16_t characteristic_handle_{0};
|
||||
// Notification descriptor of the MIDI characteristic; the link is ready once the device accepts the write to it
|
||||
uint16_t cccd_handle_{0};
|
||||
// The ATT MTU the link negotiated; every packet has to fit it
|
||||
uint16_t mtu_{23};
|
||||
bool notify_registered_{false};
|
||||
// Write without response has lower latency and is preferred when the
|
||||
// characteristic supports it.
|
||||
bool write_without_response_{false};
|
||||
const bool pair_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -1,198 +0,0 @@
|
||||
#include "midi_parser.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
static MessageType type_for_status(uint8_t status) {
|
||||
if (status >= 0xF8)
|
||||
return MessageType::SYSTEM_REAL_TIME;
|
||||
if (status >= 0xF1 && status <= 0xF6)
|
||||
return MessageType::SYSTEM_COMMON;
|
||||
switch (status & 0xF0) {
|
||||
case 0x80:
|
||||
return MessageType::NOTE_OFF;
|
||||
case 0x90:
|
||||
return MessageType::NOTE_ON;
|
||||
case 0xA0:
|
||||
return MessageType::POLY_AFTERTOUCH;
|
||||
case 0xB0:
|
||||
return MessageType::CONTROL_CHANGE;
|
||||
case 0xC0:
|
||||
return MessageType::PROGRAM_CHANGE;
|
||||
case 0xD0:
|
||||
return MessageType::CHANNEL_AFTERTOUCH;
|
||||
case 0xE0:
|
||||
return MessageType::PITCH_BEND;
|
||||
default:
|
||||
return MessageType::UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// Data bytes that follow a channel-voice or system-common status byte
|
||||
uint8_t MidiParser::expected_data_bytes(uint8_t status) {
|
||||
if (status >= 0xF0) {
|
||||
switch (status) {
|
||||
case 0xF1: // MIDI Time Code Quarter Frame
|
||||
case 0xF3: // Song Select
|
||||
return 1;
|
||||
case 0xF2: // Song Position Pointer
|
||||
return 2;
|
||||
default: // 0xF4, 0xF5 undefined, 0xF6 Tune Request
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
switch (status & 0xF0) {
|
||||
case 0xC0: // Program Change
|
||||
case 0xD0: // Channel Aftertouch
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
void MidiParser::reset() {
|
||||
this->running_status_ = 0;
|
||||
this->sysex_active_ = false;
|
||||
this->sysex_length_ = 0;
|
||||
this->current_length_ = 0;
|
||||
}
|
||||
|
||||
void MidiParser::sysex_push_(uint8_t byte) {
|
||||
if (this->sysex_length_ < this->sysex_capacity_) {
|
||||
this->sysex_buffer_[this->sysex_length_++] = byte;
|
||||
} else {
|
||||
this->sysex_overflow_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ends the SysEx being assembled; it is complete when 0xF7 closed it and every byte fit the buffer
|
||||
void MidiParser::end_sysex_(bool complete, MidiMessageHandler handler, void *ctx) {
|
||||
this->sysex_active_ = false;
|
||||
this->running_status_ = 0;
|
||||
this->emit_(complete && !this->sysex_overflow_ ? MessageType::SYSEX : MessageType::SYSEX_INCOMPLETE,
|
||||
this->sysex_buffer_, this->sysex_length_, handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::emit_current_(MidiMessageHandler handler, void *ctx) {
|
||||
const uint8_t status = this->current_message_[0];
|
||||
MessageType type = type_for_status(status);
|
||||
// A Note On with velocity zero is canonically a Note Off.
|
||||
if (type == MessageType::NOTE_ON && this->current_message_[2] == 0)
|
||||
type = MessageType::NOTE_OFF;
|
||||
// System Common clears running status; channel-voice messages set it.
|
||||
this->running_status_ = status >= 0xF0 ? 0 : status;
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(type, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
|
||||
// Takes a data byte for the message being assembled, or starts one from the running status
|
||||
void MidiParser::take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx) {
|
||||
if (this->current_length_ == 0) {
|
||||
if (this->running_status_ == 0) {
|
||||
// Data byte without any status context.
|
||||
this->emit_(MessageType::UNKNOWN, byte, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
this->current_message_[0] = this->running_status_;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(this->running_status_);
|
||||
}
|
||||
this->current_message_[this->current_length_++] = *byte;
|
||||
if (this->current_length_ > this->current_expected_)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
|
||||
void MidiParser::feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx) {
|
||||
// A BLE-MIDI packet is at least a header byte plus one timestamp or SysEx
|
||||
// continuation byte, and the header byte always has bit 7 set. Anything else
|
||||
// is malformed.
|
||||
if (length < 2 || (data[0] & 0x80) == 0) {
|
||||
for (size_t i = 0; i < length; i++)
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// A timestamp byte precedes every status byte, even a real-time byte inside another message, so a byte with
|
||||
// bit 7 set is a status byte only right after a timestamp and a timestamp everywhere else
|
||||
bool expect_timestamp = true;
|
||||
for (size_t i = 1; i < length; i++) {
|
||||
const uint8_t byte = data[i];
|
||||
if ((byte & 0x80) == 0) {
|
||||
// A data byte continues the SysEx, the message being assembled, or the running status
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(byte);
|
||||
} else {
|
||||
this->take_data_(data + i, handler, ctx);
|
||||
}
|
||||
expect_timestamp = true;
|
||||
continue;
|
||||
}
|
||||
if (expect_timestamp) {
|
||||
expect_timestamp = false;
|
||||
continue;
|
||||
}
|
||||
expect_timestamp = true;
|
||||
if (byte >= 0xF8) {
|
||||
// Real-time messages are single bytes that interrupt anything, a SysEx included, without affecting it
|
||||
this->emit_(MessageType::SYSTEM_REAL_TIME, data + i, 1, handler, ctx);
|
||||
continue;
|
||||
}
|
||||
// Any other status byte cuts a message still being assembled short
|
||||
if (this->current_length_ > 0) {
|
||||
const uint16_t length = this->current_length_;
|
||||
this->current_length_ = 0;
|
||||
this->emit_(MessageType::INCOMPLETE, this->current_message_, length, handler, ctx);
|
||||
}
|
||||
if (byte == STATUS_SYSEX_END) {
|
||||
if (this->sysex_active_) {
|
||||
this->sysex_push_(STATUS_SYSEX_END);
|
||||
this->end_sysex_(true, handler, ctx);
|
||||
} else {
|
||||
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Any status byte but 0xF7 ends a SysEx still open
|
||||
if (this->sysex_active_)
|
||||
this->end_sysex_(false, handler, ctx);
|
||||
if (byte == STATUS_SYSEX_START) {
|
||||
this->sysex_active_ = true;
|
||||
this->sysex_length_ = 0;
|
||||
this->sysex_overflow_ = false;
|
||||
this->sysex_push_(STATUS_SYSEX_START);
|
||||
continue;
|
||||
}
|
||||
this->current_message_[0] = byte;
|
||||
this->current_length_ = 1;
|
||||
this->current_expected_ = expected_data_bytes(byte);
|
||||
// A status byte without data bytes, e.g. Tune Request, is a message on its own
|
||||
if (this->current_expected_ == 0)
|
||||
this->emit_current_(handler, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size) {
|
||||
if (length == 0)
|
||||
return 0;
|
||||
const size_t skip_start = payload[0] == STATUS_SYSEX_START ? 1 : 0;
|
||||
const size_t skip_end = payload[length - 1] == STATUS_SYSEX_END ? 1 : 0;
|
||||
const size_t body = length - skip_start - skip_end;
|
||||
// 0xF0, the body, then the timestamp before 0xF7
|
||||
const size_t framed = body + 3;
|
||||
if (framed > out_size)
|
||||
return 0;
|
||||
// A body byte with bit 7 set would be read as a timestamp or status byte
|
||||
for (size_t i = 0; i < body; i++) {
|
||||
if (payload[skip_start + i] & 0x80)
|
||||
return 0;
|
||||
}
|
||||
out[0] = STATUS_SYSEX_START;
|
||||
std::memcpy(out + 1, payload + skip_start, body);
|
||||
out[body + 1] = PACKET_TIMESTAMP;
|
||||
out[body + 2] = STATUS_SYSEX_END;
|
||||
return framed;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -1,131 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_midi {
|
||||
|
||||
// Decoded MIDI message types. Status bytes have the high bit set; the low nibble of a channel-voice status byte
|
||||
// (0x80-0xEF) is the channel, system messages (0xF0-0xFF) have none.
|
||||
enum class MessageType : uint8_t {
|
||||
NOTE_OFF, // 0x80
|
||||
NOTE_ON, // 0x90
|
||||
POLY_AFTERTOUCH, // 0xA0
|
||||
CONTROL_CHANGE, // 0xB0
|
||||
PROGRAM_CHANGE, // 0xC0
|
||||
CHANNEL_AFTERTOUCH, // 0xD0
|
||||
PITCH_BEND, // 0xE0
|
||||
SYSEX, // 0xF0 ... 0xF7, possibly spread over several BLE packets
|
||||
SYSEX_INCOMPLETE, // a SysEx cut short by another status byte or longer than the buffer
|
||||
SYSTEM_COMMON, // 0xF1-0xF6, e.g. quarter frame, song position
|
||||
SYSTEM_REAL_TIME, // 0xF8-0xFF, e.g. clock, start, stop
|
||||
INCOMPLETE, // a channel or system common message cut short by another status byte
|
||||
UNKNOWN, // malformed/undecodable byte
|
||||
};
|
||||
|
||||
PROGMEM_STRING_TABLE(MessageTypeNames, "NOTE_OFF", "NOTE_ON", "POLY_AFTERTOUCH", "CONTROL_CHANGE", "PROGRAM_CHANGE",
|
||||
"CHANNEL_AFTERTOUCH", "PITCH_BEND", "SYSEX", "SYSEX_INCOMPLETE", "SYSTEM_COMMON",
|
||||
"SYSTEM_REAL_TIME", "INCOMPLETE", "UNKNOWN");
|
||||
|
||||
/// A decoded MIDI message. `data` points into the parser's storage or the packet being fed, so it is only valid
|
||||
/// for the duration of the handler.
|
||||
struct MidiMessage {
|
||||
// Status byte followed by its data bytes; a SysEx includes the 0xF0/0xF7 wrappers
|
||||
const uint8_t *data{nullptr};
|
||||
uint16_t length{0};
|
||||
MessageType type{MessageType::UNKNOWN};
|
||||
|
||||
uint8_t status() const { return this->data[0]; }
|
||||
uint8_t channel() const { return this->data[0] & 0x0F; }
|
||||
// The data bytes of a complete channel-voice message: note and velocity, controller and value, or program
|
||||
uint8_t data1() const { return this->data[1]; }
|
||||
uint8_t data2() const { return this->data[2]; }
|
||||
// PITCH_BEND: 14-bit value re-centered on zero (-8192..8191).
|
||||
int16_t pitch_bend() const {
|
||||
return static_cast<int16_t>((static_cast<uint16_t>(this->data[2]) << 7) | this->data[1]) - 8192;
|
||||
}
|
||||
};
|
||||
|
||||
/// Called once per decoded message with the context passed to feed().
|
||||
using MidiMessageHandler = void (*)(void *ctx, const MidiMessage &message);
|
||||
|
||||
/// Decoder for the BLE-MIDI packet format (Apple/MMA "Bluetooth Low Energy
|
||||
/// MIDI" specification, section 3).
|
||||
///
|
||||
/// Packet layout:
|
||||
/// [header] [timestamp] [MIDI 0] ... ([timestamp] [MIDI n])*
|
||||
///
|
||||
/// - header : 0b10hhhhhh, bit 7 set, bits 5-0 hold the high bits of the
|
||||
/// timestamp
|
||||
/// - timestamp : 0b1lllllll, bit 7 set, bits 6-0 hold the low bits of the
|
||||
/// timestamp
|
||||
///
|
||||
/// A timestamp byte precedes every status byte, except where running status
|
||||
/// applies. SysEx messages may span several BLE packets and may be interrupted
|
||||
/// by real-time messages (0xF8-0xFF).
|
||||
///
|
||||
/// Timestamps are used for jitter reconstruction by MIDI hosts and are ignored
|
||||
/// here.
|
||||
///
|
||||
/// This class holds no BLE or ESPHome state so that it can be exercised in
|
||||
/// isolation.
|
||||
class MidiParser final {
|
||||
public:
|
||||
/// `sysex_buffer` reassembles SysEx messages; one longer than `sysex_capacity` bytes is reported incomplete.
|
||||
MidiParser(uint8_t *sysex_buffer, uint16_t sysex_capacity)
|
||||
: sysex_buffer_(sysex_buffer), sysex_capacity_(sysex_capacity) {}
|
||||
|
||||
uint16_t sysex_capacity() const { return this->sysex_capacity_; }
|
||||
|
||||
/// Drop all parser state. Call on disconnect so running status and partial
|
||||
/// messages do not leak into the next session.
|
||||
void reset();
|
||||
|
||||
/// Feed one BLE notification payload. Undecodable bytes are reported as MessageType::UNKNOWN so the
|
||||
/// caller can log them.
|
||||
void feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx);
|
||||
|
||||
/// Number of data bytes that follow a status byte other than SysEx.
|
||||
static uint8_t expected_data_bytes(uint8_t status);
|
||||
|
||||
protected:
|
||||
void emit_(MessageType type, const uint8_t *data, uint16_t length, MidiMessageHandler handler, void *ctx) {
|
||||
handler(ctx, MidiMessage{data, length, type});
|
||||
}
|
||||
void emit_current_(MidiMessageHandler handler, void *ctx);
|
||||
void take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx);
|
||||
void end_sysex_(bool complete, MidiMessageHandler handler, void *ctx);
|
||||
void sysex_push_(uint8_t byte);
|
||||
|
||||
// SysEx reassembly across packets.
|
||||
uint8_t *const sysex_buffer_;
|
||||
const uint16_t sysex_capacity_;
|
||||
uint16_t sysex_length_{0};
|
||||
bool sysex_active_{false};
|
||||
bool sysex_overflow_{false};
|
||||
|
||||
// Last channel-voice status byte, sticky across bytes and packets until a new
|
||||
// status byte or a system-common message clears it (MIDI running status).
|
||||
uint8_t running_status_{0};
|
||||
|
||||
// Message being assembled: status byte plus up to two data bytes.
|
||||
uint8_t current_message_[3]{};
|
||||
uint8_t current_length_{0};
|
||||
uint8_t current_expected_{0};
|
||||
};
|
||||
|
||||
// A BLE-MIDI packet starts with a header byte and a timestamp byte, both with bit 7 set. The remaining
|
||||
// timestamp bits serve jitter reconstruction only and are left at zero when transmitting.
|
||||
static constexpr uint8_t PACKET_HEADER = 0x80;
|
||||
static constexpr uint8_t PACKET_TIMESTAMP = 0x80;
|
||||
static constexpr uint8_t STATUS_SYSEX_START = 0xF0;
|
||||
static constexpr uint8_t STATUS_SYSEX_END = 0xF7;
|
||||
|
||||
/// Frame a SysEx payload for one BLE-MIDI packet: 0xF0 and 0xF7 are added when the payload lacks them, with
|
||||
/// the timestamp byte the specification requires before 0xF7. Returns the bytes written to `out`, or 0 when
|
||||
/// the payload is empty, holds a byte with bit 7 set, or does not fit `out_size` once framed.
|
||||
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size);
|
||||
|
||||
} // namespace esphome::ble_midi
|
||||
@@ -1,4 +1,3 @@
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -47,24 +46,16 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
|
||||
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
UNIT_KELVIN,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
TemperatureUnitResolver,
|
||||
convert_visual_temperatures,
|
||||
declare_fixed_temperature_unit,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
setup_entity_temperature_unit,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType, SafeExpType
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -147,33 +138,30 @@ CONF_MIN_HUMIDITY = "min_humidity"
|
||||
CONF_MAX_HUMIDITY = "max_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
|
||||
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
||||
|
||||
|
||||
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
|
||||
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Remove before 2027.5.0: external components imported it before temperature_with_unit replaced it
|
||||
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
|
||||
|
||||
|
||||
def visual_temperature_step(value: Any) -> ConfigType:
|
||||
# Allow defining target/current temperature steps separately
|
||||
if isinstance(value, dict):
|
||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
|
||||
|
||||
# Otherwise, use the single value for both properties
|
||||
value = cv.temperature_with_unit(value)
|
||||
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
|
||||
|
||||
|
||||
_visual_temperatures_in_entity_unit = partial(
|
||||
convert_visual_temperatures,
|
||||
delta_keys=(CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE),
|
||||
delta_section=CONF_TEMPERATURE_STEP,
|
||||
)
|
||||
value = visual_temperature(value)
|
||||
return VISUAL_TEMPERATURE_STEP_SCHEMA(
|
||||
{
|
||||
CONF_TARGET_TEMPERATURE: value,
|
||||
CONF_CURRENT_TEMPERATURE: value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA = (
|
||||
@@ -184,8 +172,8 @@ _CLIMATE_SCHEMA = (
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
|
||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
|
||||
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
|
||||
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
|
||||
@@ -262,24 +250,6 @@ _CLIMATE_SCHEMA = (
|
||||
|
||||
|
||||
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
|
||||
_CLIMATE_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
|
||||
|
||||
|
||||
def _final_validate(config: list[ConfigType]) -> None:
|
||||
# Runs once for the whole domain; converts what waited for a platform's unit resolver
|
||||
full_config = fv.full_config.get()
|
||||
for entry in config:
|
||||
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _unit_mqtt_can_report(value: str) -> str:
|
||||
# MQTT discovery only knows °C and °F
|
||||
if value == UNIT_KELVIN and "mqtt" in CORE.loaded_integrations:
|
||||
raise cv.Invalid("MQTT reports climate temperatures in °C or °F only")
|
||||
return value
|
||||
|
||||
|
||||
def climate_schema(
|
||||
@@ -287,26 +257,10 @@ def climate_schema(
|
||||
*,
|
||||
entity_category: str = cv.UNDEFINED,
|
||||
icon: str = cv.UNDEFINED,
|
||||
temperature_unit: bool = False,
|
||||
fixed_temperature_unit: str | TemperatureUnitResolver | None = None,
|
||||
) -> cv.Schema:
|
||||
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
|
||||
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
|
||||
``fixed_temperature_unit`` is for a device that reports in that unit only, set by its traits,
|
||||
or a resolver ``(entity config, full config) -> unit`` when its own options pick the unit."""
|
||||
if temperature_unit and fixed_temperature_unit is not None:
|
||||
raise ValueError(
|
||||
"a platform takes unit_of_measurement or has a fixed unit, not both"
|
||||
)
|
||||
if fixed_temperature_unit is not None:
|
||||
declare_fixed_temperature_unit(class_, fixed_temperature_unit)
|
||||
schema = {
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
}
|
||||
if temperature_unit:
|
||||
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.All(
|
||||
cv.temperature_unit, _unit_mqtt_can_report
|
||||
)
|
||||
|
||||
for key, default, validator in [
|
||||
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
|
||||
@@ -351,7 +305,6 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
|
||||
if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None:
|
||||
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
|
||||
cg.add(var.set_visual_max_humidity_override(max_humidity))
|
||||
setup_entity_temperature_unit(var, config)
|
||||
|
||||
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
@@ -526,9 +479,9 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(Climate),
|
||||
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
|
||||
@@ -544,23 +497,17 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
TARGET_TEMPERATURE_APPLY_FIELDS = tuple(
|
||||
automation.ApplyField(key, setter, cg.float_, temperature_in_entity_unit)
|
||||
for key, setter in (
|
||||
(CONF_TARGET_TEMPERATURE, "set_target_temperature"),
|
||||
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low"),
|
||||
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high"),
|
||||
)
|
||||
)
|
||||
TARGET_TEMPERATURE_KEYS = tuple(
|
||||
field.conf_key for field in TARGET_TEMPERATURE_APPLY_FIELDS
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"climate.control",
|
||||
CLIMATE_CONTROL_ACTION_SCHEMA,
|
||||
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
|
||||
*TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
|
||||
),
|
||||
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
||||
automation.ApplyField(
|
||||
|
||||
@@ -77,10 +77,6 @@ class ClimateTraits {
|
||||
friend class Climate; // Climate wires its custom mode lists into traits
|
||||
|
||||
public:
|
||||
// The visual range a platform starts from, in °C
|
||||
static constexpr float DEFAULT_VISUAL_MIN_TEMPERATURE = 10.0f;
|
||||
static constexpr float DEFAULT_VISUAL_MAX_TEMPERATURE = 30.0f;
|
||||
|
||||
// Out of line on purpose: an inlined default init makes GCC copy the defaults from a .rodata
|
||||
// template per traits() override, and .rodata is RAM on ESP8266.
|
||||
ClimateTraits();
|
||||
@@ -197,8 +193,8 @@ class ClimateTraits {
|
||||
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
|
||||
|
||||
uint32_t feature_flags_{0};
|
||||
float visual_min_temperature_{DEFAULT_VISUAL_MIN_TEMPERATURE};
|
||||
float visual_max_temperature_{DEFAULT_VISUAL_MAX_TEMPERATURE};
|
||||
float visual_min_temperature_{10};
|
||||
float visual_max_temperature_{30};
|
||||
float visual_target_temperature_step_{0.1};
|
||||
float visual_current_temperature_step_{0.1};
|
||||
float visual_min_humidity_{30};
|
||||
|
||||
@@ -33,11 +33,9 @@ ClimateIR = climate_ir_ns.class_(
|
||||
|
||||
def climate_ir_schema(
|
||||
class_: MockObjClass,
|
||||
*,
|
||||
fixed_temperature_unit: str | None = None,
|
||||
) -> cv.Schema:
|
||||
return (
|
||||
climate.climate_schema(class_, fixed_temperature_unit=fixed_temperature_unit)
|
||||
climate.climate_schema(class_)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
|
||||
@@ -54,12 +52,8 @@ def climate_ir_schema(
|
||||
|
||||
def climate_ir_with_receiver_schema(
|
||||
class_: MockObjClass,
|
||||
*,
|
||||
fixed_temperature_unit: str | None = None,
|
||||
) -> cv.Schema:
|
||||
return climate_ir_schema(
|
||||
class_, fixed_temperature_unit=fixed_temperature_unit
|
||||
).extend(
|
||||
return climate_ir_schema(class_).extend(
|
||||
{
|
||||
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
|
||||
remote_base.RemoteReceiverBase
|
||||
|
||||
@@ -5,7 +5,6 @@ from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@epicRE"]
|
||||
AUTO_LOAD = ["number"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "delta_wallbox"
|
||||
MULTI_CONF = True
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -55,8 +54,6 @@ static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0
|
||||
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
|
||||
|
||||
static constexpr uint16_t REG_MAX_CURRENT = 0x0112;
|
||||
|
||||
// Indexed by DeltaState
|
||||
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
|
||||
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
|
||||
@@ -106,13 +103,6 @@ static bool verify_crc(const uint8_t *data, size_t len) {
|
||||
return calculated == received;
|
||||
}
|
||||
|
||||
static uint64_t read_be(const uint8_t *p, size_t n) {
|
||||
uint64_t value = 0;
|
||||
for (size_t i = 0; i < n; i++)
|
||||
value = (value << 8) | p[i];
|
||||
return value;
|
||||
}
|
||||
|
||||
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
|
||||
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
|
||||
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
||||
@@ -458,16 +448,15 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
uint8_t byte_count = frame[2];
|
||||
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
||||
// Parsing 0x011B charging info (9 registers)
|
||||
uint64_t power_raw = std::min<uint64_t>(read_be(&frame[3], 7), 999900);
|
||||
uint64_t energy_raw = read_be(&frame[10], 4);
|
||||
uint8_t hours = std::min<uint8_t>(frame[14], 99);
|
||||
uint8_t minutes = std::min<uint8_t>(frame[15], 59);
|
||||
uint8_t seconds = std::min<uint8_t>(frame[16], 59);
|
||||
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
|
||||
uint8_t minutes = frame[15];
|
||||
uint8_t hours = frame[16];
|
||||
uint8_t state_byte = frame[17];
|
||||
|
||||
float power_kw = power_raw / 1000.0f;
|
||||
float energy_kwh = energy_raw / 1000.0f;
|
||||
float charging_minutes = hours * 60.0f + minutes + seconds / 60.0f;
|
||||
float charging_minutes = hours * 60 + minutes;
|
||||
|
||||
if (this->power_sensor_ != nullptr)
|
||||
this->power_sensor_->publish_state(power_kw);
|
||||
@@ -482,13 +471,11 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
||||
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
||||
// Parsing 0x0112 max output setting (2 registers)
|
||||
// frame[4] is the configured max (set by DIP switch in device)
|
||||
// frame[5] the active limit
|
||||
this->max_current_limit_ = frame[4];
|
||||
if (this->max_current_sensor_ != nullptr)
|
||||
this->max_current_sensor_->publish_state(frame[5]);
|
||||
if (this->max_current_number_ != nullptr)
|
||||
this->max_current_number_->publish_state(frame[5]);
|
||||
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
float amps = current_raw / 10.0f;
|
||||
if (this->max_current_sensor_ != nullptr) {
|
||||
this->max_current_sensor_->publish_state(amps);
|
||||
}
|
||||
|
||||
// Poll the error code register every cycle
|
||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||
@@ -501,15 +488,8 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
this->error_code_text_sensor_->publish_state(hex_str);
|
||||
}
|
||||
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
||||
const uint16_t register_addr = (static_cast<uint16_t>(frame[2]) << 8) | frame[3];
|
||||
if (register_addr == REG_MAX_CURRENT) {
|
||||
ESP_LOGI(TAG, "Wallbox max current command acknowledged: %u A", frame[5]);
|
||||
if (this->max_current_number_ != nullptr)
|
||||
this->max_current_number_->publish_state(frame[5]);
|
||||
} else {
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -523,7 +503,6 @@ void DeltaWallbox::reset_state_() {
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
||||
this->max_current_limit_ = 0; // refuse writes until the next 0x0112 read
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning();
|
||||
@@ -554,35 +533,6 @@ void DeltaWallbox::set_charging_state(bool state) {
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::set_max_current(float current) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGW(TAG, "Cannot set maximum current while wallbox is not fully connected.");
|
||||
return;
|
||||
}
|
||||
const uint8_t upper = this->max_current_limit_;
|
||||
if (upper == 0) {
|
||||
ESP_LOGW(TAG, "Wallbox current limit not read yet; refusing to set %.0f A", current);
|
||||
return;
|
||||
}
|
||||
const int32_t rounded = std::lround(current);
|
||||
if (rounded < 1) {
|
||||
ESP_LOGW(TAG, "Rejecting maximum current %d A", static_cast<int>(rounded));
|
||||
return;
|
||||
}
|
||||
const uint8_t requested = static_cast<uint8_t>(std::min<int32_t>(rounded, upper));
|
||||
if (rounded > upper) {
|
||||
ESP_LOGW(TAG, "Requested %d A exceeds wallbox limit %u A; using %u A", static_cast<int>(rounded), upper, upper);
|
||||
}
|
||||
ESP_LOGI(TAG, "Setting wallbox maximum current to %u A", requested);
|
||||
const std::array<uint8_t, 6> body = {0x01,
|
||||
FC_WRITE_SINGLE_REGISTER,
|
||||
static_cast<uint8_t>(REG_MAX_CURRENT >> 8),
|
||||
static_cast<uint8_t>(REG_MAX_CURRENT & 0xFF),
|
||||
0x00,
|
||||
requested};
|
||||
this->send_frame_(body.data(), body.size());
|
||||
}
|
||||
|
||||
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
||||
|
||||
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <array>
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/number/number.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -57,14 +56,12 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
||||
SUB_SENSOR(charging_time)
|
||||
SUB_SENSOR(charger_state)
|
||||
SUB_SENSOR(max_current)
|
||||
SUB_NUMBER(max_current) // settable max-current number entity
|
||||
SUB_TEXT_SENSOR(serial_number)
|
||||
SUB_TEXT_SENSOR(fw_version)
|
||||
SUB_TEXT_SENSOR(bt_fw_version)
|
||||
SUB_TEXT_SENSOR(model_name)
|
||||
SUB_TEXT_SENSOR(error_code)
|
||||
void set_charging_state(bool state);
|
||||
void set_max_current(float current);
|
||||
void start_charging();
|
||||
void stop_charging();
|
||||
|
||||
@@ -98,7 +95,6 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
|
||||
uint16_t tx_char_handle_{0};
|
||||
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
||||
uint8_t pairing_attempts_{0};
|
||||
uint8_t max_current_limit_{0};
|
||||
bool has_pin_{false};
|
||||
bool pairing_in_progress_{false};
|
||||
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MAX_CURRENT, ENTITY_CATEGORY_CONFIG, UNIT_AMPERE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_MAX_CURRENT): number.number_schema(
|
||||
number.ApplyNumber,
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon="mdi:flash",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
numbers = number.sub_numbers(config, parent=config[CONF_DELTA_WALLBOX_ID])
|
||||
await numbers(
|
||||
CONF_MAX_CURRENT,
|
||||
hub.set_max_current_number,
|
||||
min_value=6,
|
||||
max_value=32,
|
||||
step=1,
|
||||
apply="set_max_current(value)",
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR, UNIT_FAHRENHEIT
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -13,10 +13,7 @@ FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
|
||||
Model = friedrich_ns.enum("Model", is_class=True)
|
||||
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
|
||||
|
||||
# The unit reports in Fahrenheit (traits() in friedrich.cpp), so visual temperatures are kept in it
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||
FriedrichClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
).extend(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
|
||||
{
|
||||
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
|
||||
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
|
||||
|
||||
@@ -15,25 +15,34 @@ ESPHOME_LOG_TAG(TAG, "http_request.ota");
|
||||
|
||||
void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); };
|
||||
|
||||
OtaHttpRequestComponent::Request &OtaHttpRequestComponent::get_request_() {
|
||||
if (this->request_ == nullptr)
|
||||
this->request_ = make_unique<Request>();
|
||||
return *this->request_;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_md5_url(const std::string &url) {
|
||||
if (!this->validate_url_(url)) {
|
||||
this->md5_url_.clear(); // URL was not valid; prevent flashing until it is
|
||||
if (this->request_ != nullptr)
|
||||
this->request_->md5_url.clear(); // URL was not valid; prevent flashing until it is
|
||||
return;
|
||||
}
|
||||
this->md5_url_ = url;
|
||||
this->md5_expected_.clear(); // to be retrieved later
|
||||
Request &request = this->get_request_();
|
||||
request.md5_url = url;
|
||||
request.md5_expected.clear(); // to be retrieved later
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_url(const std::string &url) {
|
||||
if (!this->validate_url_(url)) {
|
||||
this->url_.clear(); // URL was not valid; prevent flashing until it is
|
||||
if (this->request_ != nullptr)
|
||||
this->request_->url.clear(); // URL was not valid; prevent flashing until it is
|
||||
return;
|
||||
}
|
||||
this->url_ = url;
|
||||
this->get_request_().url = url;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::flash() {
|
||||
if (this->url_.empty()) {
|
||||
if (this->request_ == nullptr || this->request_->url.empty()) {
|
||||
ESP_LOGE(TAG, "URL not set; cannot start update");
|
||||
return;
|
||||
}
|
||||
@@ -58,8 +67,8 @@ void OtaHttpRequestComponent::flash() {
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status);
|
||||
#endif
|
||||
this->md5_computed_.clear(); // will be reset at next attempt
|
||||
this->md5_expected_.clear(); // will be reset at next attempt
|
||||
this->request_->md5_computed.clear(); // will be reset at next attempt
|
||||
this->request_->md5_expected.clear(); // will be reset at next attempt
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -81,18 +90,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
md5::MD5Digest md5_receive;
|
||||
char md5_receive_str[33];
|
||||
|
||||
if (this->md5_expected_.empty() && !this->http_get_md5_()) {
|
||||
if (this->request_->md5_expected.empty() && !this->http_get_md5_()) {
|
||||
return OTA_MD5_INVALID;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "MD5 expected: %s", this->md5_expected_.c_str());
|
||||
ESP_LOGD(TAG, "MD5 expected: %s", this->request_->md5_expected.c_str());
|
||||
|
||||
auto url_with_auth = this->get_url_with_auth_(this->url_);
|
||||
auto url_with_auth = this->get_url_with_auth_(this->request_->url);
|
||||
if (url_with_auth.empty()) {
|
||||
return OTA_BAD_URL;
|
||||
}
|
||||
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->url_.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->request_->url.c_str());
|
||||
|
||||
auto container = this->parent_->get(url_with_auth);
|
||||
|
||||
@@ -179,9 +188,9 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
// verify MD5 is as expected and act accordingly
|
||||
md5_receive.calculate();
|
||||
md5_receive.get_hex(md5_receive_str);
|
||||
this->md5_computed_ = md5_receive_str;
|
||||
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
|
||||
this->request_->md5_computed = md5_receive_str;
|
||||
if (strncmp(this->request_->md5_computed.c_str(), this->request_->md5_expected.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->request_->md5_computed.c_str());
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
|
||||
} else {
|
||||
@@ -223,11 +232,15 @@ static std::string url_encode(const std::string &str) {
|
||||
return result;
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); }
|
||||
void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); }
|
||||
void OtaHttpRequestComponent::set_password(const std::string &password) {
|
||||
this->get_request_().password = url_encode(password);
|
||||
}
|
||||
void OtaHttpRequestComponent::set_username(const std::string &username) {
|
||||
this->get_request_().username = url_encode(username);
|
||||
}
|
||||
|
||||
std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) {
|
||||
if (this->username_.empty() || this->password_.empty()) {
|
||||
if (this->request_->username.empty() || this->request_->password.empty()) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -240,23 +253,23 @@ std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url)
|
||||
ESP_LOGD(TAG, "Using basic HTTP authentication");
|
||||
|
||||
start_char += 3; // skip '://' characters
|
||||
auto url_with_auth =
|
||||
url.substr(0, start_char) + this->username_ + ":" + this->password_ + "@" + url.substr(start_char);
|
||||
auto url_with_auth = url.substr(0, start_char) + this->request_->username + ":" + this->request_->password + "@" +
|
||||
url.substr(start_char);
|
||||
return url_with_auth;
|
||||
}
|
||||
|
||||
bool OtaHttpRequestComponent::http_get_md5_() {
|
||||
if (this->md5_url_.empty()) {
|
||||
if (this->request_->md5_url.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto url_with_auth = this->get_url_with_auth_(this->md5_url_);
|
||||
auto url_with_auth = this->get_url_with_auth_(this->request_->md5_url);
|
||||
if (url_with_auth.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->md5_url_.c_str());
|
||||
ESP_LOGI(TAG, "Connecting to: %s", this->request_->md5_url.c_str());
|
||||
auto container = this->parent_->get(url_with_auth);
|
||||
if (container == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to connect to MD5 URL");
|
||||
@@ -273,8 +286,8 @@ bool OtaHttpRequestComponent::http_get_md5_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
this->md5_expected_.resize(MD5_SIZE);
|
||||
auto result = http_read_fully(container.get(), (uint8_t *) this->md5_expected_.data(), MD5_SIZE, MD5_SIZE,
|
||||
this->request_->md5_expected.resize(MD5_SIZE);
|
||||
auto result = http_read_fully(container.get(), (uint8_t *) this->request_->md5_expected.data(), MD5_SIZE, MD5_SIZE,
|
||||
this->parent_->get_timeout());
|
||||
container->end();
|
||||
|
||||
|
||||
@@ -27,29 +27,36 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
void set_md5_url(const std::string &md5_url);
|
||||
void set_md5(const std::string &md5) { this->md5_expected_ = md5; }
|
||||
void set_md5(const std::string &md5) { this->get_request_().md5_expected = md5; }
|
||||
void set_password(const std::string &password);
|
||||
void set_url(const std::string &url);
|
||||
void set_username(const std::string &username);
|
||||
|
||||
std::string md5_computed() { return this->md5_computed_; }
|
||||
std::string md5_expected() { return this->md5_expected_; }
|
||||
std::string md5_computed() { return this->request_ != nullptr ? this->request_->md5_computed : std::string(); }
|
||||
std::string md5_expected() { return this->request_ != nullptr ? this->request_->md5_expected : std::string(); }
|
||||
|
||||
void flash();
|
||||
|
||||
protected:
|
||||
/// Allocated by the first setter, which only runs before a flash, and never freed, so a flash never loses it.
|
||||
struct Request {
|
||||
std::string md5_computed;
|
||||
std::string md5_expected;
|
||||
std::string md5_url;
|
||||
std::string password;
|
||||
std::string username;
|
||||
std::string url;
|
||||
};
|
||||
Request &get_request_();
|
||||
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
|
||||
// Only reached from flash(), which returns early while request_ is null
|
||||
uint8_t do_ota_();
|
||||
std::string get_url_with_auth_(const std::string &url);
|
||||
bool http_get_md5_();
|
||||
bool validate_url_(const std::string &url);
|
||||
|
||||
std::string md5_computed_{};
|
||||
std::string md5_expected_{};
|
||||
std::string md5_url_{};
|
||||
std::string password_{};
|
||||
std::string username_{};
|
||||
std::string url_{};
|
||||
std::unique_ptr<Request> request_;
|
||||
int status_ = -1;
|
||||
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SENSOR, UNIT_FAHRENHEIT
|
||||
from esphome.const import CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
@@ -11,10 +11,7 @@ CODEOWNERS = ["@ipopov"]
|
||||
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
||||
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
||||
|
||||
# The unit reports in Fahrenheit (traits() in islandaire.cpp), so visual temperatures are kept in it
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
|
||||
IslandaireClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
).extend(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
|
||||
{
|
||||
# The unit has no auto (heat/cool) mode
|
||||
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// clang-tidy cannot parse the Realtek SDK headers behind wiring_data.h
|
||||
#if defined(USE_BK72XX) || (defined(USE_RTL87XX) && !defined(CLANG_TIDY))
|
||||
#include <wiring_data.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::libretiny {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "libretiny.gpio");
|
||||
@@ -31,10 +26,16 @@ static int IRAM_ATTR flags_to_mode(gpio::Flags flags) {
|
||||
}
|
||||
}
|
||||
|
||||
// The ISR copy is the pin itself, so both share the stored mode and level. Pins are never freed.
|
||||
struct ISRPinArg {
|
||||
uint8_t pin;
|
||||
bool inverted;
|
||||
};
|
||||
|
||||
ISRInternalGPIOPin ArduinoInternalGPIOPin::to_isr() const {
|
||||
return ISRInternalGPIOPin(
|
||||
const_cast<ArduinoInternalGPIOPin *>(this)); // NOLINT(cppcoreguidelines-pro-type-const-cast)
|
||||
auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
arg->pin = pin_;
|
||||
arg->inverted = inverted_;
|
||||
return ISRInternalGPIOPin((void *) arg);
|
||||
}
|
||||
|
||||
void ArduinoInternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const {
|
||||
@@ -60,59 +61,8 @@ void ArduinoInternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg,
|
||||
attachInterruptParam(pin_, func, arduino_mode, arg);
|
||||
}
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
// BDK GPIO config register (REG_GPIO_0_CONFIG, GCFG_OUTPUT_BIT): 0 enables the output, bit 1 is the output level
|
||||
static constexpr uintptr_t BK_GPIO_CFG_BASE = 0x00802800;
|
||||
static constexpr uint32_t BK_GPIO_OUTPUT_BIT = 1 << 1;
|
||||
|
||||
// LibreTiny's switch to output always starts the pin LOW. Enable the output at its level in one store, then update
|
||||
// LibreTiny's pin state the way its own switch does (cores/beken-72xx/arduino/src/wiring_digital.c).
|
||||
static bool bk_enter_output(uint8_t pin, bool level) {
|
||||
PinInfo *info = pinInfo(pin);
|
||||
if (info == nullptr || info->gpio > 31)
|
||||
return false;
|
||||
PinData *data = pinData(info);
|
||||
if (pinEnabled(info, PIN_GPIO) && data->gpioMode == OUTPUT)
|
||||
return false;
|
||||
pinRemoveMode(info, PIN_PWM);
|
||||
auto *cfg =
|
||||
reinterpret_cast<volatile uint32_t *>(BK_GPIO_CFG_BASE + info->gpio * 4); // NOLINT(performance-no-int-to-ptr)
|
||||
*cfg = level ? BK_GPIO_OUTPUT_BIT : 0;
|
||||
pinEnable(info, PIN_GPIO);
|
||||
data->gpioMode = OUTPUT;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_RTL87XX) && !defined(CLANG_TIDY)
|
||||
// The switch to output keeps the HAL data register, so write the level to it first
|
||||
static void rtl_preset_level(uint8_t pin, bool level) {
|
||||
PinInfo *info = pinInfo(pin);
|
||||
if (info == nullptr)
|
||||
return;
|
||||
if (info->data == nullptr || info->data->gpio == nullptr)
|
||||
pinMode(pin, INPUT); // NOLINT - LibreTiny creates the gpio_t on first use
|
||||
if (info->data != nullptr && info->data->gpio != nullptr)
|
||||
gpio_write(info->data->gpio, level);
|
||||
}
|
||||
#endif
|
||||
|
||||
void ArduinoInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
const int mode = flags_to_mode(flags);
|
||||
this->output_ = flags & gpio::FLAG_OUTPUT;
|
||||
#ifdef USE_BK72XX
|
||||
if (mode == OUTPUT && bk_enter_output(this->pin_, this->level_))
|
||||
return;
|
||||
#endif
|
||||
#if defined(USE_RTL87XX) && !defined(CLANG_TIDY)
|
||||
// The preset avoids a LOW blip when going HIGH, but a LOW only takes after the switch (measured on a BW15),
|
||||
// so the write below is still needed
|
||||
if (mode == OUTPUT)
|
||||
rtl_preset_level(this->pin_, this->level_);
|
||||
#endif
|
||||
pinMode(this->pin_, mode); // NOLINT
|
||||
if (this->output_)
|
||||
digitalWrite(this->pin_, this->level_); // NOLINT
|
||||
pinMode(pin_, flags_to_mode(flags)); // NOLINT
|
||||
}
|
||||
|
||||
size_t ArduinoInternalGPIOPin::dump_summary(char *buffer, size_t len) const {
|
||||
@@ -120,12 +70,10 @@ size_t ArduinoInternalGPIOPin::dump_summary(char *buffer, size_t len) const {
|
||||
}
|
||||
|
||||
bool ArduinoInternalGPIOPin::digital_read() {
|
||||
return bool(digitalRead(this->pin_)) ^ this->inverted_; // NOLINT
|
||||
return bool(digitalRead(pin_)) ^ inverted_; // NOLINT
|
||||
}
|
||||
void ArduinoInternalGPIOPin::digital_write(bool value) {
|
||||
this->level_ = value ^ this->inverted_;
|
||||
if (this->output_)
|
||||
digitalWrite(this->pin_, this->level_); // NOLINT
|
||||
digitalWrite(pin_, value ^ inverted_); // NOLINT
|
||||
}
|
||||
void ArduinoInternalGPIOPin::detach_interrupt() const {
|
||||
detachInterrupt(pin_); // NOLINT
|
||||
@@ -138,16 +86,20 @@ namespace esphome {
|
||||
using namespace libretiny;
|
||||
|
||||
bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
|
||||
return reinterpret_cast<ArduinoInternalGPIOPin *>(this->arg_)->digital_read();
|
||||
auto *arg = reinterpret_cast<ISRPinArg *>(arg_);
|
||||
return bool(digitalRead(arg->pin)) ^ arg->inverted; // NOLINT
|
||||
}
|
||||
void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) {
|
||||
reinterpret_cast<ArduinoInternalGPIOPin *>(this->arg_)->digital_write(value);
|
||||
auto *arg = reinterpret_cast<ISRPinArg *>(arg_);
|
||||
digitalWrite(arg->pin, value ^ arg->inverted); // NOLINT
|
||||
}
|
||||
void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() {
|
||||
detachInterrupt(reinterpret_cast<ArduinoInternalGPIOPin *>(this->arg_)->get_pin());
|
||||
auto *arg = reinterpret_cast<ISRPinArg *>(arg_);
|
||||
detachInterrupt(arg->pin);
|
||||
}
|
||||
void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
reinterpret_cast<ArduinoInternalGPIOPin *>(this->arg_)->pin_mode(flags);
|
||||
auto *arg = reinterpret_cast<ISRPinArg *>(arg_);
|
||||
pinMode(arg->pin, flags_to_mode(flags)); // NOLINT
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -30,12 +30,7 @@ class ArduinoInternalGPIOPin final : public GPIOPin {
|
||||
void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const;
|
||||
|
||||
uint8_t pin_;
|
||||
bool inverted_ : 1 {};
|
||||
// LibreTiny drops writes to a pin that is not an output; keep the level until it becomes one.
|
||||
// Remove with bk_enter_output() and rtl_preset_level() once LibreTiny handles this.
|
||||
// These bits share a byte with the ISR copy, so a pin must not be written from an ISR and the loop at once.
|
||||
bool output_ : 1 {};
|
||||
bool level_ : 1 {};
|
||||
bool inverted_{};
|
||||
gpio::Flags flags_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -12,12 +12,8 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UART_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_USE_FAHRENHEIT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -73,17 +69,7 @@ def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _temperature_unit(config: ConfigType, full_config: ConfigType) -> str:
|
||||
# The hub's use_fahrenheit picks the unit in traits(); a legacy climate-owned hub has no option
|
||||
if (hub_id := config.get(CONF_MITSUBISHI_CN105_ID)) is None:
|
||||
return UNIT_CELSIUS
|
||||
hub = fv.get_declaring_config(hub_id, full_config)
|
||||
return UNIT_FAHRENHEIT if hub.get(CONF_USE_FAHRENHEIT) else UNIT_CELSIUS
|
||||
|
||||
|
||||
_BASE_SCHEMA = climate.climate_schema(
|
||||
MitsubishiCN105Climate, fixed_temperature_unit=_temperature_unit
|
||||
).extend(
|
||||
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
from esphome import automation, preferences
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import logger, network, socket
|
||||
from esphome.components.const import CONF_VERIFY_SSL
|
||||
@@ -107,7 +107,6 @@ def AUTO_LOAD():
|
||||
CONF_TLS = "tls"
|
||||
CONF_IDF_SEND_ASYNC = "idf_send_async"
|
||||
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
|
||||
CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions"
|
||||
CONF_TRANSPORT = "transport"
|
||||
CONF_WS_PATH = "ws_path"
|
||||
|
||||
@@ -305,10 +304,6 @@ def validate_config(value):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_WS_PATH}' is only valid when 'transport' is 'ws' or 'wss'"
|
||||
)
|
||||
if value.get(CONF_PERSIST_SUBSCRIPTIONS) and value.get(CONF_CLEAN_SESSION):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_PERSIST_SUBSCRIPTIONS}' requires '{CONF_CLEAN_SESSION}: false'"
|
||||
)
|
||||
|
||||
# Populate default fields
|
||||
out = value.copy()
|
||||
@@ -399,7 +394,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_USERNAME, default=""): cv.string,
|
||||
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
|
||||
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PERSIST_SUBSCRIPTIONS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_CLIENT_ID): cv.string,
|
||||
cv.Optional(
|
||||
CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP
|
||||
@@ -672,10 +666,6 @@ async def to_code(config):
|
||||
cg.add(var.set_username(config[CONF_USERNAME]))
|
||||
cg.add(var.set_password(config[CONF_PASSWORD]))
|
||||
cg.add(var.set_clean_session(config[CONF_CLEAN_SESSION]))
|
||||
if config[CONF_PERSIST_SUBSCRIPTIONS]:
|
||||
cg.add_define("USE_MQTT_SESSION_PERSISTENCE")
|
||||
# The session fingerprint, a uint32_t kept in RTC memory
|
||||
preferences.reserve_preferences(1, 4, in_flash=False)
|
||||
cg.add_optional(var.set_client_id, config.get(CONF_CLIENT_ID))
|
||||
|
||||
discovery = config[CONF_DISCOVERY]
|
||||
|
||||
@@ -39,7 +39,7 @@ class MQTTBackend {
|
||||
public:
|
||||
using on_connect_callback_t = void(bool session_present);
|
||||
using on_disconnect_callback_t = void(MQTTClientDisconnectReason reason);
|
||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t return_code);
|
||||
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t qos);
|
||||
using on_unsubscribe_callback_t = void(uint16_t packet_id);
|
||||
using on_message_callback_t = void(const char *topic, const char *payload, size_t len, size_t index, size_t total);
|
||||
using on_publish_user_callback_t = void(uint16_t packet_id);
|
||||
|
||||
@@ -170,9 +170,8 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) {
|
||||
|
||||
case MQTT_EVENT_SUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
// One topic per SUBSCRIBE, so data[0] is its return code
|
||||
this->on_subscribe_.call((int) event.msg_id,
|
||||
event.data.empty() ? SUBACK_FAILURE : static_cast<uint8_t>(event.data[0]));
|
||||
// hardcode QoS to 0. QoS is not used in this context but required to mirror the AsyncMqtt interface
|
||||
this->on_subscribe_.call((int) event.msg_id, 0);
|
||||
break;
|
||||
case MQTT_EVENT_UNSUBSCRIBED:
|
||||
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
|
||||
@@ -225,16 +224,6 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b
|
||||
if (msg_id == instance->tracked_msg_id_.load())
|
||||
instance->tracked_acked_.store(1);
|
||||
}
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Also counted here: the queue holds 31 events, too few for the SUBACKs of a device with many command topics
|
||||
if (event_id == MQTT_EVENT_CONNECTED) {
|
||||
instance->subacks_.reset();
|
||||
} else if (event_id == MQTT_EVENT_SUBSCRIBED) {
|
||||
const auto *mqtt_event = static_cast<esp_mqtt_event_t *>(event_data);
|
||||
// One topic per SUBSCRIBE, so data[0] is its return code
|
||||
instance->subacks_.add(mqtt_event->data_len > 0 ? static_cast<uint8_t>(mqtt_event->data[0]) : SUBACK_FAILURE);
|
||||
}
|
||||
#endif
|
||||
// queue event to decouple processing from ESP-IDF MQTT task to main loop
|
||||
auto *event = instance->mqtt_event_pool_.allocate();
|
||||
if (event == nullptr) {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
|
||||
namespace esphome::mqtt {
|
||||
|
||||
@@ -215,9 +214,6 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
||||
using MQTTBackend::publish;
|
||||
bool publish_tracked(const MQTTMessage &message) final;
|
||||
bool publish_acknowledged() const final { return this->tracked_acked_.load(std::memory_order_acquire) != 0; }
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
const SubackCounter &get_subacks() const { return this->subacks_; }
|
||||
#endif
|
||||
|
||||
void loop() final;
|
||||
|
||||
@@ -298,10 +294,6 @@ class MQTTBackendESP32 final : public MQTTBackend {
|
||||
std::atomic<uint16_t> acked_msg_id_{0};
|
||||
// uint8_t, not bool: GCC on Xtensa emits an out-of-line call for atomic<bool>
|
||||
std::atomic<uint8_t> tracked_acked_{0};
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Counted in the ESP-IDF MQTT task, see mqtt_event_handler()
|
||||
SubackCounter subacks_;
|
||||
#endif
|
||||
// Pool sized to queue capacity (SIZE-1) because LockFreeQueue<T,N> is a ring
|
||||
// buffer that holds N-1 elements (one slot distinguishes full from empty).
|
||||
// This guarantees allocate() returns nullptr before push() can fail, which:
|
||||
|
||||
@@ -21,16 +21,6 @@
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
#ifdef USE_ESP8266
|
||||
#include "esphome/core/preferences.h"
|
||||
#elif defined(USE_ESP32)
|
||||
#include <esp_attr.h>
|
||||
#include <soc/soc_caps.h>
|
||||
#include "esphome/core/preferences_rtc.h"
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DASHBOARD_IMPORT
|
||||
#include "esphome/components/dashboard_import/dashboard_import.h"
|
||||
#endif
|
||||
@@ -42,39 +32,6 @@ ESPHOME_LOG_TAG(TAG, "mqtt");
|
||||
// Maximum number of MQTT component resends per loop iteration.
|
||||
// Limits work to avoid triggering the task watchdog on reconnect.
|
||||
static constexpr uint8_t MAX_RESENDS_PER_LOOP = 8;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
static constexpr uint32_t SESSION_FINGERPRINT_HASH = fnv1a_hash("mqtt_session_fingerprint");
|
||||
#ifdef USE_ESP8266
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static ESPPreferenceObject s_session_fingerprint_pref;
|
||||
#elif defined(USE_ESP32) && SOC_RTC_MEM_SUPPORTED
|
||||
#define USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT
|
||||
// Own 8 bytes rather than core's RTC preference pool, which would add 248 bytes of RTC RAM (usable memory on
|
||||
// variants with RTC fast memory only). The fingerprint and its checksum; power-on garbage fails the checksum.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RTC_NOINIT_ATTR uint32_t s_session_fingerprint[2];
|
||||
#endif
|
||||
|
||||
// RTC memory where available; RAM only otherwise, so a reset resubscribes
|
||||
static void load_session_fingerprint([[maybe_unused]] uint32_t *fingerprint) {
|
||||
#ifdef USE_ESP8266
|
||||
s_session_fingerprint_pref = global_preferences->make_preference<uint32_t>(SESSION_FINGERPRINT_HASH, false);
|
||||
s_session_fingerprint_pref.load(fingerprint);
|
||||
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
|
||||
rtc_pref_decode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<uint8_t *>(fingerprint),
|
||||
sizeof(*fingerprint));
|
||||
#endif
|
||||
}
|
||||
|
||||
static void store_session_fingerprint([[maybe_unused]] const uint32_t *fingerprint) {
|
||||
#ifdef USE_ESP8266
|
||||
s_session_fingerprint_pref.save(fingerprint);
|
||||
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
|
||||
rtc_pref_encode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<const uint8_t *>(fingerprint),
|
||||
sizeof(*fingerprint));
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
|
||||
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
|
||||
@@ -123,19 +80,6 @@ void MQTTClientComponent::setup() {
|
||||
this->state_ = MQTT_CLIENT_DISCONNECTED;
|
||||
this->disconnect_reason_ = reason;
|
||||
});
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->mqtt_backend_.set_on_connect([this](bool session_present) {
|
||||
this->session_present_ = session_present;
|
||||
#ifndef USE_ESP32
|
||||
// AsyncMqttClient delivers CONNACK and SUBACK in the same context, so the counter has one writer
|
||||
this->subacks_.reset();
|
||||
#endif
|
||||
});
|
||||
#ifndef USE_ESP32
|
||||
this->mqtt_backend_.set_on_subscribe([this](uint16_t, uint8_t return_code) { this->subacks_.add(return_code); });
|
||||
#endif
|
||||
load_session_fingerprint(&this->session_fingerprint_);
|
||||
#endif
|
||||
#ifdef USE_LOGGER
|
||||
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
|
||||
logger::global_logger->add_log_callback(
|
||||
@@ -417,9 +361,6 @@ void MQTTClientComponent::start_connect_() {
|
||||
this->last_will_.payload.c_str());
|
||||
}
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->session_present_ = false;
|
||||
#endif
|
||||
this->mqtt_backend_.connect();
|
||||
this->state_ = MQTT_CLIENT_CONNECTING;
|
||||
this->connect_begin_ = millis();
|
||||
@@ -444,30 +385,14 @@ void MQTTClientComponent::check_connected() {
|
||||
// MQTT Client needs some time to be fully set up.
|
||||
delay(100); // NOLINT
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
this->subscribes_sent_ = 0;
|
||||
const bool resumed =
|
||||
session_has_subscriptions(this->session_present_, this->session_fingerprint_, this->subscriptions_fingerprint_());
|
||||
this->subacks_settled_ = resumed;
|
||||
if (resumed) {
|
||||
for (auto &subscription : this->subscriptions_)
|
||||
subscription.subscribed = true;
|
||||
} else {
|
||||
// Untrusted until all are acknowledged again, so a reset part way through does not trust a partial session;
|
||||
// topics only an older firmware used stay subscribed in the session
|
||||
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
|
||||
}
|
||||
#endif
|
||||
this->resubscribe_subscriptions_();
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Otherwise sent once the SUBACKs are in, so its own SUBACK is never counted
|
||||
if (resumed) {
|
||||
this->subscribe_crash_log_();
|
||||
// Best effort: a refused subscription is tried again at the next connection
|
||||
if (this->crash_log_offered_) {
|
||||
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
|
||||
format_esphome_topic(topic, "logs");
|
||||
this->subscribe_(topic, 0);
|
||||
}
|
||||
#else
|
||||
this->subscribe_crash_log_();
|
||||
#endif
|
||||
#endif
|
||||
this->send_device_info_();
|
||||
|
||||
@@ -516,12 +441,7 @@ void MQTTClientComponent::loop() {
|
||||
}
|
||||
|
||||
this->last_connected_ = now;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
if (this->resubscribe_subscriptions_() && !this->subacks_settled_)
|
||||
this->check_subacks_();
|
||||
#else
|
||||
this->resubscribe_subscriptions_();
|
||||
#endif
|
||||
|
||||
// Process pending resends for all MQTT components centrally
|
||||
// Limit work per loop iteration to avoid triggering task WDT on reconnect
|
||||
@@ -579,69 +499,15 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
|
||||
if (do_resub) {
|
||||
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
|
||||
sub->resubscribe_timeout = now;
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Only the subscriptions the fingerprint covers are counted
|
||||
if (sub->subscribed)
|
||||
this->subscribes_sent_++;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
bool MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
bool all_subscribed = true;
|
||||
void MQTTClientComponent::resubscribe_subscriptions_() {
|
||||
for (auto &subscription : this->subscriptions_) {
|
||||
this->resubscribe_subscription_(&subscription);
|
||||
all_subscribed &= subscription.subscribed;
|
||||
}
|
||||
return all_subscribed;
|
||||
}
|
||||
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Out of line so the topic buffer is not part of loop()'s stack frame
|
||||
__attribute__((noinline)) uint32_t MQTTClientComponent::subscriptions_fingerprint_() {
|
||||
// Seeded with the config hash, so a new firmware config sends every subscription once
|
||||
uint32_t fingerprint = App.get_config_hash();
|
||||
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||
for (const auto &subscription : this->subscriptions_)
|
||||
fingerprint = add_subscription_fingerprint(fingerprint, subscription.topic.build(buf).c_str(), subscription.qos);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
void MQTTClientComponent::check_subacks_() {
|
||||
#ifdef USE_ESP32
|
||||
const SubackTally tally = this->mqtt_backend_.get_subacks().tally(this->subscribes_sent_);
|
||||
#else
|
||||
const SubackTally tally = this->subacks_.tally(this->subscribes_sent_);
|
||||
#endif
|
||||
if (tally == SubackTally::SUBACK_TALLY_PENDING)
|
||||
return;
|
||||
this->subacks_settled_ = true;
|
||||
// Trusted only once the broker granted every SUBSCRIBE, so a reset or a lost link before that sends them again,
|
||||
// and a refused one is tried again at the next connect
|
||||
if (tally == SubackTally::SUBACK_TALLY_ALL_GRANTED)
|
||||
this->set_session_fingerprint_(this->subscriptions_fingerprint_());
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
this->subscribe_crash_log_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void MQTTClientComponent::set_session_fingerprint_(uint32_t fingerprint) {
|
||||
if (this->session_fingerprint_ == fingerprint)
|
||||
return;
|
||||
this->session_fingerprint_ = fingerprint;
|
||||
store_session_fingerprint(&this->session_fingerprint_);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
void MQTTClientComponent::subscribe_crash_log_() {
|
||||
// Best effort: a refused subscription is tried again at the next connection
|
||||
if (!this->crash_log_offered_)
|
||||
return;
|
||||
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
|
||||
format_esphome_topic(topic, "logs");
|
||||
this->subscribe_(topic, 0);
|
||||
}
|
||||
|
||||
void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) {
|
||||
// Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash
|
||||
// log for every line it logs
|
||||
@@ -706,10 +572,6 @@ __attribute__((noinline)) static bool topic_equals(const MQTTTopicSource &source
|
||||
}
|
||||
|
||||
void MQTTClientComponent::unsubscribe(const std::string &topic) {
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
// Before the broker drops the topic, so a reset in between cannot leave a matching fingerprint
|
||||
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
|
||||
#endif
|
||||
bool ret = this->mqtt_backend_.unsubscribe(topic.c_str());
|
||||
yield();
|
||||
if (ret) {
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
#elif defined(USE_LIBRETINY)
|
||||
#include "mqtt_backend_libretiny.h"
|
||||
#endif
|
||||
#if defined(USE_MQTT_SESSION_PERSISTENCE) && !defined(USE_ESP32)
|
||||
#include "mqtt_subscription_fingerprint.h"
|
||||
#endif
|
||||
#include "lwip/ip_addr.h"
|
||||
|
||||
#include <span>
|
||||
@@ -383,18 +380,10 @@ class MQTTClientComponent final : public Component {
|
||||
}
|
||||
void erase_removed_subscriptions_();
|
||||
#ifdef USE_MQTT_CRASH_LOG
|
||||
void subscribe_crash_log_();
|
||||
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
|
||||
#endif
|
||||
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
|
||||
/// Returns true when every subscription is subscribed
|
||||
bool resubscribe_subscriptions_();
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
uint32_t subscriptions_fingerprint_();
|
||||
/// Once every SUBACK of this connection is in, saves the fingerprint if all were granted
|
||||
void check_subacks_();
|
||||
void set_session_fingerprint_(uint32_t fingerprint);
|
||||
#endif
|
||||
void resubscribe_subscriptions_();
|
||||
|
||||
MQTTCredentials credentials_;
|
||||
/// The last will message. Disabled optional denotes it being default and
|
||||
@@ -451,18 +440,6 @@ class MQTTClientComponent final : public Component {
|
||||
bool wait_for_connection_{false};
|
||||
bool dispatching_{false};
|
||||
bool removed_while_dispatching_{false};
|
||||
#ifdef USE_MQTT_SESSION_PERSISTENCE
|
||||
bool session_present_{false};
|
||||
/// Set once every SUBACK of this connection is in (or the session was resumed without subscribing)
|
||||
bool subacks_settled_{false};
|
||||
/// SUBSCRIBEs sent on this connection for the subscriptions the fingerprint covers
|
||||
uint16_t subscribes_sent_{0};
|
||||
#ifndef USE_ESP32
|
||||
SubackCounter subacks_;
|
||||
#endif
|
||||
/// Fingerprint of the set the broker session is known to hold in full; 0 while a rebuild is in progress
|
||||
uint32_t session_fingerprint_{0};
|
||||
#endif
|
||||
};
|
||||
|
||||
extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::mqtt {
|
||||
|
||||
/// Adds one subscription to a fingerprint of a set of subscriptions; the order they are added in does not matter.
|
||||
inline uint32_t add_subscription_fingerprint(uint32_t fingerprint, const char *topic, uint8_t qos) {
|
||||
return fingerprint + fnv1a_hash_extend(fnv1a_hash(topic), qos);
|
||||
}
|
||||
|
||||
/// Session fingerprint while a rebuild is in progress, and after a power loss or a cold boot.
|
||||
static constexpr uint32_t UNTRUSTED_SESSION_FINGERPRINT = 0;
|
||||
|
||||
/// True when a resumed session holds in full the subscriptions it is trusted with, and they are the current ones.
|
||||
constexpr bool session_has_subscriptions(bool session_present, uint32_t trusted, uint32_t current) {
|
||||
return session_present && trusted != UNTRUSTED_SESSION_FINGERPRINT && trusted == current;
|
||||
}
|
||||
|
||||
/// First SUBACK return code that refuses a subscription; also stands in for a SUBACK with no return code.
|
||||
static constexpr uint8_t SUBACK_FAILURE = 0x80;
|
||||
|
||||
/// True when a SUBACK return code grants the subscription.
|
||||
constexpr bool subscribe_granted(uint8_t return_code) { return return_code < SUBACK_FAILURE; }
|
||||
|
||||
enum class SubackTally : uint8_t {
|
||||
SUBACK_TALLY_PENDING,
|
||||
SUBACK_TALLY_ALL_GRANTED,
|
||||
SUBACK_TALLY_NOT_ALL_GRANTED,
|
||||
};
|
||||
|
||||
/// SUBACKs received on the current connection. Written only by the context that delivers broker events (so plain
|
||||
/// loads and stores, no read-modify-write), read by the main loop.
|
||||
class SubackCounter {
|
||||
public:
|
||||
void reset() {
|
||||
this->received_.store(0, std::memory_order_relaxed);
|
||||
this->granted_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
void add(uint8_t return_code) {
|
||||
if (subscribe_granted(return_code))
|
||||
this->granted_.store(this->granted_.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||
// Released after granted_, so a reader that sees this SUBACK in received_ also sees it in granted_
|
||||
this->received_.store(this->received_.load(std::memory_order_relaxed) + 1, std::memory_order_release);
|
||||
}
|
||||
/// Compares the SUBACKs received with the number of SUBSCRIBEs sent on this connection.
|
||||
SubackTally tally(uint16_t sent) const {
|
||||
const uint16_t received = this->received_.load(std::memory_order_acquire);
|
||||
if (received < sent)
|
||||
return SubackTally::SUBACK_TALLY_PENDING;
|
||||
// More than sent means a SUBACK from an earlier connection was counted, so none can be trusted
|
||||
return received == sent && this->granted_.load(std::memory_order_relaxed) == sent
|
||||
? SubackTally::SUBACK_TALLY_ALL_GRANTED
|
||||
: SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::atomic<uint16_t> received_{0};
|
||||
std::atomic<uint16_t> granted_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::mqtt
|
||||
@@ -44,7 +44,6 @@ from esphome.const import (
|
||||
CONF_INVERTED,
|
||||
CONF_NUMBER,
|
||||
CONF_OTA,
|
||||
CONF_PLATFORM,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SAFE_MODE,
|
||||
CONF_TOOLCHAIN,
|
||||
@@ -310,8 +309,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
|
||||
): cv.Schema(
|
||||
{
|
||||
# Defaults to whether MCUboot is present, see _final_validate
|
||||
cv.Optional(CONF_ENABLE_OTA_ROLLBACK): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_ENABLE_OTA_ROLLBACK, default=True
|
||||
): cv.boolean,
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -370,21 +370,7 @@ def _final_validate(config):
|
||||
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
|
||||
)
|
||||
|
||||
# Rollback confirms a freshly flashed image through MCUboot, which is either
|
||||
# the bootloader or, for zephyr_mcumgr, a second stage after it.
|
||||
mcuboot = (
|
||||
config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
|
||||
or "zephyr_mcumgr" in CORE.loaded_integrations
|
||||
)
|
||||
if advanced.get(CONF_ENABLE_OTA_ROLLBACK) and not mcuboot:
|
||||
# Older configs set it explicitly; it never had an effect without MCUboot
|
||||
_LOGGER.warning(
|
||||
"'%s' requires MCUboot, which the '%s' bootloader does not have; ignoring it",
|
||||
CONF_ENABLE_OTA_ROLLBACK,
|
||||
config[KEY_BOOTLOADER],
|
||||
)
|
||||
advanced[CONF_ENABLE_OTA_ROLLBACK] = False
|
||||
if advanced.setdefault(CONF_ENABLE_OTA_ROLLBACK, mcuboot):
|
||||
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
|
||||
# "disabled: false" means safe mode *is* enabled.
|
||||
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
|
||||
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
|
||||
@@ -848,58 +834,26 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: PYOCD upload
|
||||
|
||||
if mcumgr_device:
|
||||
# MCUboot over USB CDC: the serial port speaks the MCUmgr SMP protocol
|
||||
asyncio.run(_smpmgr_upload(mcumgr_device))
|
||||
return True # Handled: mcumgr serial upload
|
||||
|
||||
# Deferred imports: bleak and friends are heavy, only load for BLE paths
|
||||
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
|
||||
from .ble_logger import is_ble_address
|
||||
from .ota import smpmgr_scan, smpmgr_upload
|
||||
|
||||
if host == "BLE" or is_ble_address(host):
|
||||
asyncio.run(_upload_over_ble(config, host))
|
||||
return True # Handled: BLE OTA upload
|
||||
if host == "BLE":
|
||||
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
|
||||
|
||||
if is_ble_address(host):
|
||||
mcumgr_device = host
|
||||
|
||||
if mcumgr_device:
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
|
||||
return True # Handled: mcumgr OTA upload
|
||||
|
||||
return False # Not handled: let caller try default upload methods
|
||||
|
||||
|
||||
async def _smpmgr_upload(device: str) -> None:
|
||||
"""Upload the MCUboot application image over the MCUmgr SMP protocol."""
|
||||
from .ota import smpmgr_upload
|
||||
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
await smpmgr_upload(device, firmware)
|
||||
|
||||
|
||||
async def _upload_over_ble(config: ConfigType, host: str) -> None:
|
||||
"""Upload over the OTA protocol the firmware was built with."""
|
||||
from .ota import smpmgr_scan
|
||||
|
||||
# Read the config, not CORE.loaded_integrations: the validated config cache
|
||||
# skips validation, which is what fills loaded_integrations.
|
||||
if not any(
|
||||
ota.get(CONF_PLATFORM) == "adafruit_ble" for ota in config.get(CONF_OTA, [])
|
||||
):
|
||||
address = await smpmgr_scan(CORE.name) if host == "BLE" else host
|
||||
await _smpmgr_upload(address)
|
||||
return
|
||||
|
||||
from esphome.components.adafruit_ble import dfu
|
||||
|
||||
# A broken package must not leave a working device waiting in its bootloader
|
||||
package = dfu.load_firmware(CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE)
|
||||
address = host
|
||||
if host == "BLE":
|
||||
try:
|
||||
address = await smpmgr_scan(CORE.name)
|
||||
except EsphomeError as err:
|
||||
raise EsphomeError(f"{err}. {dfu.BOOTLOADER_HINT}") from err
|
||||
bootloader_address = await dfu.enter_dfu_mode(address)
|
||||
await dfu.dfu_upload(bootloader_address, package)
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
|
||||
address = devices[0]
|
||||
from .ble_logger import is_ble_address, logger_connect, logger_scan
|
||||
|
||||
@@ -9,33 +9,16 @@ ESPHOME_LOG_TAG(TAG, "rx8111");
|
||||
|
||||
// Indexed by the TSEL value stored in timer_clock_
|
||||
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
|
||||
PROGMEM_STRING_TABLE(FoutNames, "32768 Hz", "1024 Hz", "1 Hz", "off");
|
||||
PROGMEM_STRING_TABLE(EvinPullNames, "none", "pullup_500k", "pullup_1m", "pullup_10m", "pulldown_500k");
|
||||
PROGMEM_STRING_TABLE(BootFailureNames, "RX8111 battery configuration failed", "RX8111 FOUT configuration failed",
|
||||
"RX8111 EVIN configuration failed", ESP_LOG_MSG_COMM_FAIL, ESP_LOG_MSG_COMM_FAIL);
|
||||
|
||||
static constexpr uint8_t RX8111_REG_SEC = 0x10;
|
||||
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
|
||||
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
|
||||
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
|
||||
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
|
||||
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
|
||||
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
|
||||
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
|
||||
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
|
||||
static constexpr uint8_t RX8111_REG_TIMER_CONTROL = 0x2D;
|
||||
static constexpr uint8_t RX8111_REG_POWER_SWITCH = 0x32;
|
||||
static constexpr uint8_t RX8111_REG_TIMESTAMP_CONTROL = 0x34;
|
||||
static constexpr uint8_t RX8111_TIMESTAMP_EISEL = 0x04;
|
||||
static constexpr uint8_t RX8111_REG_EVIN = 0x2B;
|
||||
static constexpr uint8_t RX8111_EXTENSION_ETS = 0x04;
|
||||
static constexpr uint8_t RX8111_CONTROL_EIE = 0x04;
|
||||
static constexpr uint8_t RX8111_FLAG_EVF = 0x04;
|
||||
static constexpr uint8_t RX8111_POWER_CHGEN = 0x80;
|
||||
static constexpr uint8_t RX8111_POWER_INIEN = 0x40;
|
||||
static constexpr uint8_t RX8111_POWER_SWSEL = 0x0C;
|
||||
static constexpr uint8_t RX8111_POWER_VDD_ONLY = 0x08;
|
||||
static constexpr uint8_t RX8111_FOUT_FSEL = 0xC0;
|
||||
static constexpr uint8_t RX8111_TIMER_TMPIN = 0x02;
|
||||
static constexpr uint8_t RX8111_TIMER_MODE = 0x0F; // TBKON, TBKE, TMPIN, TSTP
|
||||
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
|
||||
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
|
||||
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
|
||||
@@ -53,121 +36,17 @@ static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
|
||||
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
|
||||
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
|
||||
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
|
||||
static constexpr uint32_t RX8111_BOOT_BATTERY_RETRY_ID = 3;
|
||||
static constexpr uint32_t RX8111_BOOT_FOUT_RETRY_ID = 4;
|
||||
static constexpr uint32_t RX8111_BOOT_EVENT_RETRY_ID = 5;
|
||||
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
|
||||
|
||||
void RX8111Component::setup() {
|
||||
if (this->battery_configured_)
|
||||
this->configure_boot_battery_();
|
||||
if (this->fout_configured_)
|
||||
this->configure_boot_fout_();
|
||||
if (this->event_configured_)
|
||||
this->configure_boot_event_();
|
||||
this->configure_boot_alarm_();
|
||||
this->configure_boot_timer_();
|
||||
this->read_time();
|
||||
if (this->watching_()) {
|
||||
if (this->alarm_enabled_ || this->watching_()) {
|
||||
this->start_event_polling_();
|
||||
}
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_event_() {
|
||||
const bool success = this->configure_event_();
|
||||
if (success) {
|
||||
this->event_boot_pending_ = false;
|
||||
if (this->watching_())
|
||||
this->start_event_polling_();
|
||||
} else {
|
||||
this->event_boot_pending_ = true;
|
||||
}
|
||||
this->boot_config_result_(BOOT_EVENT, success);
|
||||
}
|
||||
|
||||
bool RX8111Component::configure_event_() {
|
||||
// An explicit disable affects only /INT; leave retained capture settings untouched.
|
||||
if (!this->event_enabled_)
|
||||
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, 0);
|
||||
// ETS gates EVIN detection, not just timestamp publication. Preserve all other
|
||||
// extension bits, including FOUT, alarm and timer settings, and retained records.
|
||||
return this->update_register(RX8111_REG_EVIN, 0xFC, this->evin_settings_ & 0xFC) &&
|
||||
this->update_register(RX8111_REG_TIMESTAMP_CONTROL, RX8111_TIMESTAMP_EISEL, 0) &&
|
||||
this->update_register(RX8111_REG_EXTENSION, RX8111_EXTENSION_ETS, RX8111_EXTENSION_ETS) &&
|
||||
this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, RX8111_CONTROL_EIE);
|
||||
}
|
||||
|
||||
bool RX8111Component::clear_event_flag() {
|
||||
// FLAG is write-zero-to-clear; ones preserve other events, even if they latch during the write.
|
||||
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_EVF))) {
|
||||
this->set_warning_(WARN_EVENT_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
}
|
||||
this->event_flag_seen_ = false;
|
||||
this->clear_warning_(WARN_EVENT_FLAG_CLEAR);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_battery_() {
|
||||
// Own SWSEL as well as INIEN/CHGEN: 10 selects VDD when automatic switching is disabled
|
||||
// and is legal when enabled (unlike 11). Preserve SMPT and reserved bits.
|
||||
const uint8_t value = RX8111_POWER_VDD_ONLY | (this->battery_backup_ ? RX8111_POWER_INIEN : 0) |
|
||||
(this->battery_backup_ && this->battery_charging_ ? RX8111_POWER_CHGEN : 0);
|
||||
this->boot_config_result_(BOOT_BATTERY,
|
||||
this->update_register(RX8111_REG_POWER_SWITCH,
|
||||
RX8111_POWER_INIEN | RX8111_POWER_CHGEN | RX8111_POWER_SWSEL, value));
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_fout_() {
|
||||
// TMPIN routes timer interrupts to FOUT instead of /INT; do not silently change that routing.
|
||||
uint8_t timer_control;
|
||||
bool success = this->read_byte(RX8111_REG_TIMER_CONTROL, &timer_control);
|
||||
if (success && this->fout_frequency_ != 3 && (timer_control & RX8111_TIMER_TMPIN)) {
|
||||
ESP_LOGW(TAG, "FOUT is routed to the wake-up timer (TMPIN); cannot configure clock output");
|
||||
success = false;
|
||||
} else if (success) {
|
||||
success =
|
||||
this->update_register(RX8111_REG_EXTENSION, RX8111_FOUT_FSEL, static_cast<uint8_t>(this->fout_frequency_ << 6));
|
||||
}
|
||||
this->boot_config_result_(BOOT_FOUT, success);
|
||||
}
|
||||
|
||||
// A timer start clears TMPIN, which may be what failed FOUT; give it a fresh set of attempts.
|
||||
void RX8111Component::retry_fout_after_timer_start_() {
|
||||
if (!this->fout_configured_ || (this->warnings_ & WARN_FOUT) == 0)
|
||||
return;
|
||||
this->cancel_timeout(RX8111_BOOT_FOUT_RETRY_ID);
|
||||
this->boot_attempts_[BOOT_FOUT] = 0;
|
||||
this->configure_boot_fout_();
|
||||
}
|
||||
|
||||
void RX8111Component::boot_config_result_(BootFeature feature, bool success) {
|
||||
struct BootConfig {
|
||||
uint32_t retry_id;
|
||||
void (RX8111Component::*configure)();
|
||||
uint16_t warning;
|
||||
};
|
||||
static constexpr BootConfig CONFIGS[BOOT_FEATURE_COUNT] PROGMEM = {
|
||||
{RX8111_BOOT_BATTERY_RETRY_ID, &RX8111Component::configure_boot_battery_, WARN_BATTERY},
|
||||
{RX8111_BOOT_FOUT_RETRY_ID, &RX8111Component::configure_boot_fout_, WARN_FOUT},
|
||||
{RX8111_BOOT_EVENT_RETRY_ID, &RX8111Component::configure_boot_event_, WARN_EVENT},
|
||||
{RX8111_BOOT_ALARM_RETRY_ID, &RX8111Component::configure_boot_alarm_, WARN_ALARM},
|
||||
{RX8111_BOOT_TIMER_RETRY_ID, &RX8111Component::configure_boot_timer_, WARN_TIMER},
|
||||
};
|
||||
BootConfig config;
|
||||
progmem_memcpy(&config, &CONFIGS[feature], sizeof(config));
|
||||
const auto attempts = ++this->boot_attempts_[feature];
|
||||
if (success) {
|
||||
this->clear_warning_(config.warning);
|
||||
return;
|
||||
}
|
||||
this->set_warning_(config.warning, BootFailureNames::get_log_str(feature, BootFailureNames::LAST_INDEX));
|
||||
if (attempts < RX8111_BOOT_MAX_ATTEMPTS) {
|
||||
this->set_timeout(config.retry_id, this->boot_retry_delay_ms_,
|
||||
[this, configure = config.configure]() { (this->*configure)(); });
|
||||
}
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_alarm_() {
|
||||
bool success = true;
|
||||
if (this->alarm_enabled_) {
|
||||
@@ -195,7 +74,7 @@ void RX8111Component::configure_boot_alarm_() {
|
||||
} else if (this->alarm_explicitly_configured_) {
|
||||
success = this->set_alarm_interrupt_(false);
|
||||
}
|
||||
this->boot_config_result_(BOOT_ALARM, success);
|
||||
this->boot_config_result_(true, success);
|
||||
}
|
||||
|
||||
void RX8111Component::configure_boot_timer_() {
|
||||
@@ -213,10 +92,29 @@ void RX8111Component::configure_boot_timer_() {
|
||||
this->timer_retained_event_pending_ = true;
|
||||
this->start_event_polling_();
|
||||
}
|
||||
if (success && start_timer)
|
||||
this->retry_fout_after_timer_start_();
|
||||
}
|
||||
this->boot_config_result_(BOOT_TIMER, success);
|
||||
this->boot_config_result_(false, success);
|
||||
}
|
||||
|
||||
void RX8111Component::boot_config_result_(bool alarm, bool success) {
|
||||
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
|
||||
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
|
||||
++attempts;
|
||||
if (success) {
|
||||
this->clear_warning_(warning);
|
||||
return;
|
||||
}
|
||||
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
|
||||
return;
|
||||
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
|
||||
[this, alarm]() {
|
||||
if (alarm) {
|
||||
this->configure_boot_alarm_();
|
||||
} else {
|
||||
this->configure_boot_timer_();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A runtime action supersedes pending boot programming of the same feature
|
||||
@@ -246,12 +144,12 @@ void RX8111Component::update() {
|
||||
this->check_event_flags_();
|
||||
}
|
||||
|
||||
void RX8111Component::set_warning_(uint16_t bit, const LogString *message) {
|
||||
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
|
||||
this->warnings_ |= bit;
|
||||
this->status_set_warning(message);
|
||||
}
|
||||
|
||||
void RX8111Component::clear_warning_(uint16_t bit) {
|
||||
void RX8111Component::clear_warning_(uint8_t bit) {
|
||||
if ((this->warnings_ & bit) == 0)
|
||||
return;
|
||||
this->warnings_ &= ~bit;
|
||||
@@ -262,23 +160,6 @@ void RX8111Component::clear_warning_(uint16_t bit) {
|
||||
void RX8111Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "RX8111:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
if (this->battery_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " Battery backup: %s, charging: %s", ONOFF(this->battery_backup_),
|
||||
ONOFF(this->battery_charging_));
|
||||
}
|
||||
if (this->fout_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " FOUT: %s", LOG_STR_ARG(FoutNames::get_log_str(this->fout_frequency_, FoutNames::LAST_INDEX)));
|
||||
}
|
||||
if (this->event_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " EVIN interrupt: %s", ONOFF(this->event_enabled_));
|
||||
if (this->event_enabled_) {
|
||||
ESP_LOGCONFIG(
|
||||
TAG, " EVIN: %s, filter: %u, pull: %s",
|
||||
(this->evin_settings_ & 0x80) ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"),
|
||||
(this->evin_settings_ >> 5) & 3,
|
||||
LOG_STR_ARG(EvinPullNames::get_log_str((this->evin_settings_ >> 2) & 7, EvinPullNames::LAST_INDEX)));
|
||||
}
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Alarm interrupt: %s\n"
|
||||
" Wake-up timer: %s",
|
||||
@@ -300,20 +181,12 @@ void RX8111Component::read_time() {
|
||||
}
|
||||
|
||||
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
||||
if (this->time_write_invalid_) {
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||
return false;
|
||||
}
|
||||
uint8_t regs[2]; // Adjacent FLAG and CONTROL registers.
|
||||
if (!this->read_bytes(RX8111_REG_FLAG, regs, sizeof(regs))) {
|
||||
uint8_t flags;
|
||||
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
}
|
||||
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)) {
|
||||
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
|
||||
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
|
||||
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
|
||||
return false;
|
||||
@@ -326,12 +199,12 @@ bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
|
||||
return false;
|
||||
}
|
||||
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
|
||||
static constexpr uint8_t CALENDAR_MASKS[7] PROGMEM = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
||||
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
|
||||
bool valid = true;
|
||||
for (size_t i = 0; i < sizeof(buf); i++) {
|
||||
if (i == 3)
|
||||
continue; // Weekday uses one-hot encoding, not BCD.
|
||||
buf[i] &= progmem_read_byte(&CALENDAR_MASKS[i]);
|
||||
buf[i] &= CALENDAR_MASKS[i];
|
||||
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
|
||||
}
|
||||
const uint8_t onehot = buf[3];
|
||||
@@ -363,29 +236,21 @@ void RX8111Component::write_time() {
|
||||
|
||||
uint8_t buf[7];
|
||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
|
||||
this->time_write_invalid_ = true;
|
||||
const bool calendar_written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||
if (!this->finish_time_write_(calendar_written))
|
||||
return;
|
||||
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
|
||||
now.minute, now.second);
|
||||
}
|
||||
|
||||
bool RX8111Component::finish_time_write_(bool calendar_written) {
|
||||
// STOP is the battery-backed invalidity marker, including across host resets.
|
||||
// Never release a partial or delayed snapshot; only a fresh trusted write can restart it.
|
||||
if (!calendar_written) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR("RTC write incomplete; write trusted time again"));
|
||||
return false;
|
||||
}
|
||||
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS) || !this->set_stop_bit_(false)) {
|
||||
const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||
// Always try to restart the counter, even after a failed calendar write.
|
||||
const bool restarted = this->set_stop_bit_(false);
|
||||
if (!written || !restarted) {
|
||||
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return false;
|
||||
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->time_write_invalid_ = false;
|
||||
this->clear_warning_(WARN_WRITE_TIME);
|
||||
this->clear_warning_(WARN_CALENDAR);
|
||||
return true;
|
||||
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
|
||||
now.minute, now.second);
|
||||
}
|
||||
|
||||
bool RX8111Component::set_stop_bit_(bool stop) {
|
||||
@@ -518,11 +383,6 @@ bool RX8111Component::configure_timer_(bool clear_stale_flag) {
|
||||
if (!this->disable_timer_(extension, control))
|
||||
return false;
|
||||
|
||||
// Explicit starts own the timer mode: count in both power modes, do not pause,
|
||||
// and route interrupt pulses to /INT rather than FOUT. Leave reserved bits alone.
|
||||
if (!this->update_register(RX8111_REG_TIMER_CONTROL, RX8111_TIMER_MODE, 0))
|
||||
return false;
|
||||
|
||||
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
|
||||
static_cast<uint8_t>(this->timer_count_ >> 16)};
|
||||
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
|
||||
@@ -554,7 +414,6 @@ bool RX8111Component::start_timer() {
|
||||
this->timer_enabled_ = true;
|
||||
this->timer_retained_event_pending_ = false;
|
||||
this->start_event_polling_();
|
||||
this->retry_fout_after_timer_start_();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -586,12 +445,6 @@ bool RX8111Component::poll_event_flags_() {
|
||||
if (!this->read_byte(RX8111_REG_FLAG, &flags))
|
||||
return false;
|
||||
this->clear_warning_(WARN_EVENT_POLL);
|
||||
// EVF is not cleared here: users may use it to hold /INT asserted across sleep.
|
||||
const bool event_active = (flags & RX8111_FLAG_EVF) != 0;
|
||||
const bool event_rising =
|
||||
event_active && !this->event_flag_seen_ && this->event_enabled_ && !this->event_boot_pending_;
|
||||
if (this->event_enabled_ && !this->event_boot_pending_)
|
||||
this->event_flag_seen_ = event_active;
|
||||
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
|
||||
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
|
||||
// alarm_armed_ is only ever set while the alarm is enabled
|
||||
@@ -613,8 +466,6 @@ bool RX8111Component::poll_event_flags_() {
|
||||
// An alarm callback may stop the timer before its callback is delivered.
|
||||
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
|
||||
this->timer_callbacks_.call();
|
||||
if (event_rising && this->event_flag_seen_)
|
||||
this->event_callbacks_.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,28 +29,6 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
|
||||
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
|
||||
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
|
||||
void set_battery_backup(bool enabled, bool charging) {
|
||||
this->battery_configured_ = true;
|
||||
this->battery_backup_ = enabled;
|
||||
this->battery_charging_ = charging;
|
||||
}
|
||||
void set_fout_frequency(uint8_t frequency) {
|
||||
this->fout_configured_ = true;
|
||||
this->fout_frequency_ = frequency;
|
||||
}
|
||||
void set_event_config(uint8_t settings) {
|
||||
this->event_configured_ = true;
|
||||
this->event_enabled_ = true;
|
||||
this->evin_settings_ = settings;
|
||||
}
|
||||
void set_event_disabled() {
|
||||
this->event_configured_ = true;
|
||||
this->event_enabled_ = false;
|
||||
}
|
||||
template<typename F> void add_on_event_callback(F &&callback) {
|
||||
this->event_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
bool clear_event_flag();
|
||||
template<typename F> void add_on_alarm_callback(F &&callback) {
|
||||
this->alarm_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -73,7 +51,7 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
|
||||
|
||||
// One bit per fault source, so a recovery in one path cannot hide another path's fault
|
||||
enum Warning : uint16_t {
|
||||
enum Warning : uint8_t {
|
||||
WARN_CALENDAR = 1 << 0,
|
||||
WARN_EVENT_POLL = 1 << 1,
|
||||
WARN_ALARM = 1 << 2,
|
||||
@@ -81,28 +59,13 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
WARN_WRITE_TIME = 1 << 4,
|
||||
WARN_ALARM_FLAG_CLEAR = 1 << 5,
|
||||
WARN_TIMER_FLAG_CLEAR = 1 << 6,
|
||||
WARN_BATTERY = 1 << 7,
|
||||
WARN_FOUT = 1 << 8,
|
||||
WARN_EVENT = 1 << 9,
|
||||
WARN_EVENT_FLAG_CLEAR = 1 << 10,
|
||||
};
|
||||
|
||||
bool set_stop_bit_(bool stop);
|
||||
bool finish_time_write_(bool calendar_written);
|
||||
bool read_calendar_(ESPTime &rtc_time);
|
||||
void set_warning_(uint16_t bit, const LogString *message);
|
||||
void clear_warning_(uint16_t bit);
|
||||
void configure_boot_battery_();
|
||||
void configure_boot_fout_();
|
||||
void retry_fout_after_timer_start_();
|
||||
enum BootFeature : uint8_t { BOOT_BATTERY, BOOT_FOUT, BOOT_EVENT, BOOT_ALARM, BOOT_TIMER, BOOT_FEATURE_COUNT };
|
||||
void boot_config_result_(BootFeature feature, bool success);
|
||||
bool watching_() const {
|
||||
return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_ ||
|
||||
(this->event_enabled_ && !this->event_boot_pending_ && !this->event_callbacks_.empty());
|
||||
}
|
||||
void configure_boot_event_();
|
||||
bool configure_event_();
|
||||
void set_warning_(uint8_t bit, const LogString *message);
|
||||
void clear_warning_(uint8_t bit);
|
||||
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
|
||||
void check_event_flags_();
|
||||
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
|
||||
bool set_alarm_interrupt_(bool enabled);
|
||||
@@ -112,43 +75,33 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
|
||||
bool poll_event_flags_();
|
||||
void configure_boot_alarm_();
|
||||
void configure_boot_timer_();
|
||||
void boot_config_result_(bool alarm, bool success);
|
||||
void cancel_boot_retry_(bool alarm);
|
||||
|
||||
LazyCallbackManager<void()> alarm_callbacks_;
|
||||
LazyCallbackManager<void()> timer_callbacks_;
|
||||
LazyCallbackManager<void()> event_callbacks_;
|
||||
uint32_t timer_count_{0};
|
||||
uint32_t event_poll_interval_{1000};
|
||||
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
|
||||
uint16_t warnings_{0};
|
||||
uint8_t boot_attempts_[BOOT_FEATURE_COUNT]{};
|
||||
uint8_t evin_settings_{0};
|
||||
uint8_t fout_frequency_{3};
|
||||
uint8_t warnings_{0};
|
||||
uint8_t timer_clock_{2}; // 1 Hz
|
||||
uint8_t boot_alarm_attempts_{0};
|
||||
uint8_t boot_timer_attempts_{0};
|
||||
uint8_t alarm_second_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
|
||||
uint8_t alarm_day_{ALARM_FIELD_UNSET};
|
||||
bool time_write_invalid_ : 1 {false};
|
||||
bool battery_configured_ : 1 {false};
|
||||
bool battery_backup_ : 1 {false};
|
||||
bool battery_charging_ : 1 {false};
|
||||
bool fout_configured_ : 1 {false};
|
||||
bool event_configured_ : 1 {false};
|
||||
bool event_enabled_ : 1 {false};
|
||||
bool event_flag_seen_ : 1 {false};
|
||||
bool event_boot_pending_ : 1 {false};
|
||||
bool timer_enabled_ : 1 {false};
|
||||
bool timer_configured_ : 1 {false};
|
||||
bool timer_flag_seen_ : 1 {false};
|
||||
bool timer_retained_event_pending_ : 1 {false};
|
||||
bool event_polling_started_ : 1 {false};
|
||||
bool boot_alarm_event_seen_ : 1 {false};
|
||||
bool alarm_enabled_ : 1 {false};
|
||||
bool alarm_explicitly_configured_ : 1 {false};
|
||||
bool alarm_flag_seen_ : 1 {false};
|
||||
bool alarm_armed_ : 1 {false};
|
||||
bool timer_enabled_{false};
|
||||
bool timer_configured_{false};
|
||||
bool timer_flag_seen_{false};
|
||||
bool timer_retained_event_pending_{false};
|
||||
bool event_polling_started_{false};
|
||||
bool boot_alarm_event_seen_{false};
|
||||
bool alarm_enabled_{false};
|
||||
bool alarm_explicitly_configured_{false};
|
||||
bool alarm_flag_seen_{false};
|
||||
bool alarm_armed_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::rx8111
|
||||
@@ -10,7 +10,6 @@ from esphome.const import (
|
||||
CONF_HOUR,
|
||||
CONF_ID,
|
||||
CONF_MINUTE,
|
||||
CONF_ON_EVENT,
|
||||
CONF_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
@@ -21,14 +20,6 @@ DEPENDENCIES = ["i2c"]
|
||||
rx8111_ns = cg.esphome_ns.namespace("rx8111")
|
||||
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
|
||||
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
|
||||
FOUT_FREQUENCIES = {"32768hz": 0, "1024hz": 1, "1hz": 2, "off": 3}
|
||||
EVIN_PULLS = {
|
||||
"none": 0,
|
||||
"pullup_500k": 0x04,
|
||||
"pullup_1m": 0x08,
|
||||
"pullup_10m": 0x0C,
|
||||
"pulldown_500k": 0x10,
|
||||
}
|
||||
# set_alarm() ignores a field set to this value
|
||||
ALARM_FIELD_UNSET = 0xFF
|
||||
|
||||
@@ -45,13 +36,6 @@ CONF_TIMER_ENABLED = "timer_enabled"
|
||||
CONF_ON_TIMER = "on_timer"
|
||||
CONF_WEEKDAY = "weekday"
|
||||
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
|
||||
CONF_BATTERY_BACKUP = "battery_backup"
|
||||
CONF_BATTERY_CHARGING = "battery_charging"
|
||||
CONF_FOUT_FREQUENCY = "fout_frequency"
|
||||
CONF_EVENT_ENABLED = "event_enabled"
|
||||
CONF_EVENT_LEVEL = "event_level"
|
||||
CONF_EVIN_FILTER = "evin_filter"
|
||||
CONF_EVIN_PULL = "evin_pull"
|
||||
|
||||
_ALARM_FIELDS = (
|
||||
CONF_ALARM_SECOND,
|
||||
@@ -63,7 +47,6 @@ _ALARM_FIELDS = (
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_EVENT, "add_on_event_callback"),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,16 +62,6 @@ def _validate_alarm_config(config: ConfigType) -> ConfigType:
|
||||
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
|
||||
):
|
||||
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
|
||||
if CONF_BATTERY_CHARGING in config and CONF_BATTERY_BACKUP not in config:
|
||||
raise cv.Invalid("battery_charging requires an explicit battery_backup setting")
|
||||
if config.get(CONF_BATTERY_CHARGING) and not config.get(CONF_BATTERY_BACKUP):
|
||||
raise cv.Invalid("battery_charging: true requires battery_backup: true")
|
||||
if CONF_ON_EVENT in config and not config.get(CONF_EVENT_ENABLED):
|
||||
raise cv.Invalid("on_event requires event_enabled: true")
|
||||
if any(
|
||||
k in config for k in (CONF_EVENT_LEVEL, CONF_EVIN_FILTER, CONF_EVIN_PULL)
|
||||
) and not config.get(CONF_EVENT_ENABLED):
|
||||
raise cv.Invalid("EVIN settings require event_enabled: true")
|
||||
return config
|
||||
|
||||
|
||||
@@ -113,16 +86,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
TIMER_CLOCKS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
|
||||
cv.Optional(CONF_BATTERY_BACKUP): cv.boolean,
|
||||
cv.Optional(CONF_BATTERY_CHARGING): cv.boolean,
|
||||
cv.Optional(CONF_FOUT_FREQUENCY): cv.enum(FOUT_FREQUENCIES, lower=True),
|
||||
cv.Optional(CONF_EVENT_ENABLED): cv.boolean,
|
||||
cv.Optional(CONF_EVENT_LEVEL): cv.enum(
|
||||
{"low": 0, "high": 0x80}, lower=True
|
||||
),
|
||||
cv.Optional(CONF_EVIN_FILTER): cv.int_range(min=0, max=3),
|
||||
cv.Optional(CONF_EVIN_PULL): cv.enum(EVIN_PULLS, lower=True),
|
||||
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
|
||||
# Below 500 ms a scheduler interval costs more than it saves
|
||||
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
|
||||
@@ -144,7 +107,6 @@ for _method in (
|
||||
"read_time",
|
||||
"clear_alarm_flag",
|
||||
"clear_timer_flag",
|
||||
"clear_event_flag",
|
||||
"unset_alarm",
|
||||
"stop_timer",
|
||||
"start_timer",
|
||||
@@ -222,16 +184,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = await i2c.new_i2c_component(config)
|
||||
await time.register_time(var, config)
|
||||
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
|
||||
if (backup := config.get(CONF_BATTERY_BACKUP)) is not None:
|
||||
cg.add(var.set_battery_backup(backup, config.get(CONF_BATTERY_CHARGING, False)))
|
||||
cg.add_optional(var.set_fout_frequency, config.get(CONF_FOUT_FREQUENCY))
|
||||
if config.get(CONF_EVENT_ENABLED):
|
||||
level = config[CONF_EVENT_LEVEL].enum_value if CONF_EVENT_LEVEL in config else 0
|
||||
pull = config[CONF_EVIN_PULL].enum_value if CONF_EVIN_PULL in config else 0
|
||||
filter_bits = config.get(CONF_EVIN_FILTER, 0) << 5
|
||||
cg.add(var.set_event_config(level | filter_bits | pull))
|
||||
elif CONF_EVENT_ENABLED in config:
|
||||
cg.add(var.set_event_disabled())
|
||||
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
|
||||
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
|
||||
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, sensor
|
||||
@@ -29,22 +27,11 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_TEMPERATURE_HIGH,
|
||||
CONF_TARGET_TEMPERATURE_LOW,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from esphome.core.entity_helpers import (
|
||||
UNRESOLVED_PROPERTY,
|
||||
convert_temperature_keys,
|
||||
entity_temperature_unit,
|
||||
resolve_inherited_property,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import template_ns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_CURRENT_HUMIDITY = "current_humidity"
|
||||
CONF_TARGET_HUMIDITY = "target_humidity"
|
||||
CONF_SUPPORTS_ACTION = "supports_action"
|
||||
@@ -203,9 +190,6 @@ def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
|
||||
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
|
||||
)
|
||||
convert_temperature_keys(
|
||||
initial_state, climate.TARGET_TEMPERATURE_KEYS, entity_temperature_unit(config)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -215,9 +199,9 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MODE): climate.validate_climate_mode,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
|
||||
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
|
||||
cv.Exclusive(
|
||||
@@ -232,7 +216,7 @@ INITIAL_STATE_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
climate.climate_schema(TemplateClimate, temperature_unit=True)
|
||||
climate.climate_schema(TemplateClimate)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
@@ -306,30 +290,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _sensor_reports_in_the_entity_unit(config: ConfigType) -> ConfigType:
|
||||
"""A sensor's state is copied as is, so a temperature sensor must report in the climate's unit."""
|
||||
if (sensor_id := config.get(CONF_SENSOR)) is None:
|
||||
return config
|
||||
unit = entity_temperature_unit(config)
|
||||
sensor_unit = resolve_inherited_property(sensor_id, CONF_UNIT_OF_MEASUREMENT)
|
||||
if sensor_unit is UNRESOLVED_PROPERTY:
|
||||
_LOGGER.warning(
|
||||
"Could not determine the unit of sensor '%s'; it must report in %s",
|
||||
sensor_id,
|
||||
unit,
|
||||
)
|
||||
return config
|
||||
if sensor_unit in TEMPERATURE_UNITS and sensor_unit != unit:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_SENSOR}' reports in {sensor_unit} but the climate reports in {unit}",
|
||||
path=[CONF_SENSOR],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _sensor_reports_in_the_entity_unit
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -407,11 +367,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateClimate),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
|
||||
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
|
||||
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
|
||||
@@ -440,13 +400,16 @@ automation.register_apply_action(
|
||||
"climate.template.publish",
|
||||
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
|
||||
automation.ApplyField(
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
"current_temperature = {}",
|
||||
cg.float_,
|
||||
temperature_in_entity_unit,
|
||||
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
|
||||
),
|
||||
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
|
||||
*climate.TARGET_TEMPERATURE_APPLY_FIELDS,
|
||||
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
|
||||
),
|
||||
automation.ApplyField(
|
||||
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
|
||||
),
|
||||
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
|
||||
automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
@@ -23,12 +22,6 @@ class TemplateClimate final : public climate::Climate, public Component {
|
||||
climate::ClimateTraits traits() override { return this->traits_; }
|
||||
|
||||
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
|
||||
void set_temperature_unit(TemperatureUnit unit) {
|
||||
this->traits_.set_temperature_unit(unit);
|
||||
// Visual overrides still apply on top of the converted default range
|
||||
this->traits_.set_visual_min_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MIN_TEMPERATURE, unit));
|
||||
this->traits_.set_visual_max_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MAX_TEMPERATURE, unit));
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// The matching feature flag is added from codegen, so the configuration alone decides it.
|
||||
|
||||
@@ -14,7 +14,6 @@ from esphome.const import (
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core.entity_helpers import entity_temperature_unit_of_id
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -38,7 +37,7 @@ RESTORE_MODES = TemplateWaterHeaterRestoreMode.options(
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
|
||||
water_heater.water_heater_schema(TemplateWaterHeater)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
|
||||
@@ -123,8 +122,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
|
||||
cv.Optional(CONF_MODE): cv.templatable(
|
||||
water_heater.validate_water_heater_mode
|
||||
),
|
||||
@@ -143,16 +142,13 @@ async def water_heater_template_publish_to_code(
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
unit = entity_temperature_unit_of_id(config[CONF_ID])
|
||||
for key, setter in (
|
||||
(CONF_CURRENT_TEMPERATURE, var.set_current_temperature),
|
||||
(CONF_TARGET_TEMPERATURE, var.set_target_temperature),
|
||||
):
|
||||
if (value := config.get(key)) is None:
|
||||
continue
|
||||
if isinstance(value, tuple):
|
||||
value = cv.convert_temperature(*value, unit)
|
||||
cg.add(setter(await cg.templatable(value, args, cg.float_)))
|
||||
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
|
||||
template_ = await cg.templatable(current_temp, args, cg.float_)
|
||||
cg.add(var.set_current_temperature(template_))
|
||||
|
||||
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
|
||||
template_ = await cg.templatable(target_temp, args, cg.float_)
|
||||
cg.add(var.set_target_temperature(template_))
|
||||
|
||||
if mode := config.get(CONF_MODE):
|
||||
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
|
||||
|
||||
@@ -32,7 +32,6 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
|
||||
}
|
||||
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_temperature_unit(this->temperature_unit_);
|
||||
if (this->target_temperature_f_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
||||
|
||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
|
||||
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
|
||||
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
|
||||
this->supported_modes_ = modes;
|
||||
}
|
||||
@@ -55,7 +54,6 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
|
||||
TemplateLambda<bool> away_f_;
|
||||
TemplateLambda<bool> is_on_f_;
|
||||
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
|
||||
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
|
||||
water_heater::WaterHeaterModeMask supported_modes_;
|
||||
bool optimistic_{true};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -8,19 +6,15 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
convert_visual_temperatures,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
setup_entity_temperature_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -48,36 +42,19 @@ WATER_HEATER_MODES = WaterHeaterMode.options(
|
||||
)
|
||||
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
|
||||
|
||||
|
||||
_visual_temperatures_in_entity_unit = partial(
|
||||
convert_visual_temperatures, delta_keys=(CONF_TARGET_TEMPERATURE_STEP,)
|
||||
)
|
||||
|
||||
|
||||
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
|
||||
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
|
||||
_WATER_HEATER_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
|
||||
|
||||
|
||||
def _final_validate(config: list[ConfigType]) -> None:
|
||||
# Runs once for the whole domain; converts what waited for a platform's unit resolver
|
||||
full_config = fv.full_config.get()
|
||||
for entry in config:
|
||||
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def water_heater_schema(
|
||||
@@ -85,13 +62,8 @@ def water_heater_schema(
|
||||
*,
|
||||
icon: str = cv.UNDEFINED,
|
||||
entity_category: str = cv.UNDEFINED,
|
||||
temperature_unit: bool = False,
|
||||
) -> cv.Schema:
|
||||
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
|
||||
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``."""
|
||||
schema = {cv.GenerateID(): cv.declare_id(class_)}
|
||||
if temperature_unit:
|
||||
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
|
||||
|
||||
for key, default, validator in [
|
||||
(CONF_ICON, icon, cv.icon),
|
||||
@@ -116,7 +88,6 @@ async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> Non
|
||||
if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None:
|
||||
cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES")
|
||||
cg.add(var.set_visual_target_temperature_step_override(temp_step))
|
||||
setup_entity_temperature_unit(var, config)
|
||||
|
||||
|
||||
async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable:
|
||||
|
||||
@@ -218,16 +218,13 @@ WIFI_8266_PHY_MODES = WiFi8266PhyMode.options(
|
||||
)
|
||||
|
||||
WIFI_AP_MODE_FALLBACK = "FALLBACK"
|
||||
WIFI_AP_MODE_ALWAYS_ON = "ALWAYS_ON"
|
||||
WIFI_AP_MODE_WISP = "WISP"
|
||||
|
||||
|
||||
def _validate_ap_mode(value: str) -> str:
|
||||
value = cv.one_of(
|
||||
WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_ALWAYS_ON, WIFI_AP_MODE_WISP, upper=True
|
||||
)(value)
|
||||
if value != WIFI_AP_MODE_FALLBACK:
|
||||
# The AP next to the station is part of the ESP32 backend, on both of its frameworks
|
||||
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
|
||||
if value == WIFI_AP_MODE_WISP:
|
||||
# The bridge is part of the ESP32 backend, on both of its frameworks
|
||||
cv.only_on_esp32(value)
|
||||
return value
|
||||
|
||||
@@ -371,21 +368,18 @@ def wifi_network_ap(value):
|
||||
if value is None:
|
||||
value = {}
|
||||
config = WIFI_NETWORK_AP(value)
|
||||
ap_mode = config[CONF_AP_MODE]
|
||||
if ap_mode == WIFI_AP_MODE_WISP and not config.get(CONF_PASSWORD):
|
||||
raise cv.Invalid(
|
||||
"WISP mode requires a password for the access point",
|
||||
path=[CONF_PASSWORD],
|
||||
)
|
||||
if (
|
||||
ap_mode != WIFI_AP_MODE_FALLBACK
|
||||
and config[CONF_AP_TIMEOUT].total_milliseconds == 0
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"ap_timeout is how long ap_mode: {ap_mode} waits before a failed start is tried again; it cannot be 0",
|
||||
path=[CONF_AP_TIMEOUT],
|
||||
)
|
||||
if ap_mode != WIFI_AP_MODE_WISP and CONF_DNS_SERVERS in config:
|
||||
if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
||||
if not config.get(CONF_PASSWORD):
|
||||
raise cv.Invalid(
|
||||
"WISP mode requires a password for the access point",
|
||||
path=[CONF_PASSWORD],
|
||||
)
|
||||
if config[CONF_AP_TIMEOUT].total_milliseconds == 0:
|
||||
raise cv.Invalid(
|
||||
"ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0",
|
||||
path=[CONF_AP_TIMEOUT],
|
||||
)
|
||||
elif CONF_DNS_SERVERS in config:
|
||||
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
|
||||
if CONF_MANUAL_IP in config and CORE.is_rp2:
|
||||
raise cv.Invalid(
|
||||
@@ -438,15 +432,6 @@ def wifi_has_sta(config: ConfigType) -> bool:
|
||||
return bool(config.get(CONF_NETWORKS))
|
||||
|
||||
|
||||
def _has_upstream(config: ConfigType, full_config: ConfigType) -> bool:
|
||||
"""Whether a station network, ethernet or a modem can carry the AP that stays up."""
|
||||
return (
|
||||
wifi_has_sta(config)
|
||||
or CONF_ETHERNET in full_config
|
||||
or CONF_MODEM in full_config
|
||||
)
|
||||
|
||||
|
||||
# Components that save station credentials at run time
|
||||
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
|
||||
|
||||
@@ -474,34 +459,24 @@ def final_validate(config):
|
||||
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
|
||||
)
|
||||
|
||||
ap_mode = config[CONF_AP][CONF_AP_MODE] if has_ap else WIFI_AP_MODE_FALLBACK
|
||||
ap_is_wisp = ap_mode == WIFI_AP_MODE_WISP
|
||||
# The AP stays up next to an upstream: the station, or ethernet or modem
|
||||
if ap_mode != WIFI_AP_MODE_FALLBACK and not _has_upstream(config, full_config):
|
||||
raise cv.Invalid(
|
||||
f"ap_mode: {ap_mode} needs a station network (wifi.ssid or wifi.networks), "
|
||||
"or ethernet or modem as the upstream interface"
|
||||
)
|
||||
# The scan after a connect would take the AP down again; with no way for a
|
||||
# network to appear at run time there is nothing for it to roam to.
|
||||
if (
|
||||
ap_mode == WIFI_AP_MODE_ALWAYS_ON
|
||||
and config[CONF_POST_CONNECT_ROAMING]
|
||||
and not wifi_stays_ap_only(config, full_config)
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"ap_mode: ALWAYS_ON is incompatible with post_connect_roaming. "
|
||||
"Please set post_connect_roaming: false in the wifi block."
|
||||
)
|
||||
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
|
||||
if ap_is_wisp:
|
||||
# The clients are routed through the station, or through ethernet or modem
|
||||
if (
|
||||
not has_sta
|
||||
and CONF_ETHERNET not in full_config
|
||||
and CONF_MODEM not in full_config
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"WISP mode needs a station network (wifi.ssid or wifi.networks), "
|
||||
"or ethernet or modem as the upstream interface"
|
||||
)
|
||||
if has_captive_portal:
|
||||
raise cv.Invalid(
|
||||
"captive_portal is not supported together with ap_mode: WISP"
|
||||
)
|
||||
# Every client packet passes through the WiFi driver, none through the device's own sockets
|
||||
require_high_performance_wifi()
|
||||
if has_captive_portal and CONF_DNS_SERVERS in config[CONF_AP]:
|
||||
# Clients find the portal through the AP's own address as resolver, which dns_servers replaces
|
||||
raise cv.Invalid(
|
||||
"dns_servers cannot be combined with captive_portal in WISP mode",
|
||||
path=[CONF_AP, CONF_DNS_SERVERS],
|
||||
)
|
||||
if CONF_DNS_SERVERS not in config[CONF_AP]:
|
||||
# The access point passes the upstream's resolvers on, which a static address has to name
|
||||
upstreams = [
|
||||
@@ -870,15 +845,10 @@ async def to_code(config):
|
||||
# Skip the setter when the config matches the C++ initializer.
|
||||
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
|
||||
cg.add(var.set_ap_timeout(ap_timeout))
|
||||
if conf[CONF_AP_MODE] != WIFI_AP_MODE_FALLBACK:
|
||||
cg.add_define("USE_WIFI_AP_PERSISTENT")
|
||||
if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
|
||||
cg.add_define("USE_WIFI_AP_WISP")
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True)
|
||||
if "captive_portal" in CORE.config:
|
||||
# Starts without a resolver, offering its own address so lookups reach the portal
|
||||
cg.add_define("USE_WIFI_AP_WISP_PORTAL")
|
||||
if dns_servers := conf.get(CONF_DNS_SERVERS):
|
||||
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
|
||||
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
|
||||
|
||||
@@ -404,17 +404,6 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
|
||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
|
||||
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
|
||||
#endif
|
||||
// Every AP is a provisioning surface except a bridge without captive_portal
|
||||
#if defined(USE_WIFI_AP) && (!defined(USE_WIFI_AP_WISP) || defined(USE_WIFI_AP_WISP_PORTAL))
|
||||
#define WIFI_AP_HAS_PORTAL
|
||||
#endif
|
||||
// On an AP that stays up the portal follows the station: served while it is down, ended when it connects
|
||||
#if defined(USE_WIFI_AP_PERSISTENT) && defined(WIFI_AP_HAS_PORTAL) && \
|
||||
(defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
|
||||
#define WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
/// Keeps a roaming scan or a brief drop from serving the portal again
|
||||
static constexpr uint32_t WIFI_PORTAL_REOPEN_DELAY_MS = 10000;
|
||||
#endif
|
||||
|
||||
/// Cooldown duration when fallback AP is active and captive portal may be running
|
||||
/// Longer interval gives users time to configure WiFi without constant connection attempts
|
||||
@@ -668,19 +657,18 @@ void WiFiComponent::setup() {
|
||||
this->configured_power_save_ = this->power_save_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || defined(WIFI_AP_HAS_PORTAL))
|
||||
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
|
||||
// The access point, the portals on it and the DPP listen are provisioning surfaces: once the
|
||||
// window has closed, end them (mirrors the teardown on a successful connection). The blocks in
|
||||
// loop() that start them are gated so none of them is started again afterwards. An AP that
|
||||
// stays up only loses its portal.
|
||||
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
|
||||
// carries no portal and stays up.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
#ifdef USE_WIFI_DPP
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
|
||||
#ifndef USE_WIFI_AP_PERSISTENT
|
||||
if (this->ap_setup_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
@@ -693,8 +681,7 @@ void WiFiComponent::setup() {
|
||||
#endif
|
||||
this->disable_ap_();
|
||||
}
|
||||
#endif // !USE_WIFI_AP_PERSISTENT
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
#endif // USE_WIFI_AP
|
||||
});
|
||||
}
|
||||
#endif
|
||||
@@ -796,12 +783,18 @@ void WiFiComponent::start() {
|
||||
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
|
||||
this->start_initial_connection_();
|
||||
#endif
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
} else if (this->has_ap()) { // a bridge without a portal waits in loop() for the upstream's resolver
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
|
||||
this->setup_ap_config_();
|
||||
this->start_portal_scan_();
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
this->start_ap_portal_();
|
||||
#endif // WIFI_AP_HAS_PORTAL
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
}
|
||||
#ifdef USE_WIFI_DPP
|
||||
// Without networks there is nothing to try first, so listen for a configurator right away
|
||||
@@ -941,7 +934,7 @@ void WiFiComponent::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
|
||||
// Once the provisioning window has closed, don't bring up the fallback AP (or
|
||||
// the captive portal on it) - the device must stay unprovisionable until it is
|
||||
// power-cycled.
|
||||
@@ -971,13 +964,7 @@ void WiFiComponent::loop() {
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
}
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_PERSISTENT
|
||||
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
if (this->ap_setup_ && !this->is_connected() && now - this->last_connected_ > WIFI_PORTAL_REOPEN_DELAY_MS &&
|
||||
!this->is_provisioning_closed_() && !this->is_ap_portal_active_()) {
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
#endif
|
||||
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
|
||||
|
||||
#ifdef USE_IMPROV_BLE
|
||||
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
|
||||
@@ -1015,24 +1002,13 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
// The AP stays up next to the upstream, so it is started with any upstream; a start that failed is
|
||||
// tried again after ap_timeout. A bridge without a portal waits for a resolver to offer.
|
||||
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
|
||||
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_PORTAL)
|
||||
this->ap_dns_known_() &&
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
|
||||
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
|
||||
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
|
||||
ESP_LOGI(TAG, "Starting AP");
|
||||
this->ap_changed_ = now;
|
||||
this->setup_ap_config_();
|
||||
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
|
||||
// With a station the follows-station block serves the portal after its grace period
|
||||
if (!this->has_sta() && !this->is_provisioning_closed_()) {
|
||||
this->start_portal_scan_(); // as in start(), so credentials saved through the portal are tried
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1126,9 +1102,9 @@ void WiFiComponent::setup_ap_config_() {
|
||||
!this->wifi_apply_output_power_(this->output_power_)) {
|
||||
ESP_LOGV(TAG, "Setting Output Power Option failed");
|
||||
}
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
|
||||
// the STA attempts until the next try, and an AP that stays up would take clients it cannot serve.
|
||||
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
|
||||
if (!this->ap_setup_)
|
||||
this->wifi_mode_({}, false);
|
||||
#endif
|
||||
@@ -1177,21 +1153,22 @@ void WiFiComponent::pause_exclusive_ap_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::forget_ap_() {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
this->ap_offers_own_dns_ = false; // the AP goes down, so ending its portal rewrites nothing
|
||||
this->cancel_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID); // the next AP setup works out its clients' resolver itself
|
||||
#endif
|
||||
this->end_ap_portal_();
|
||||
// The AP may come back later; its clients dropped with it without a disconnect event for each
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
void WiFiComponent::fail_bridge_() {
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::disable_ap_() {
|
||||
this->forget_ap_();
|
||||
this->end_ap_portal_();
|
||||
ESP_LOGD(TAG, "Disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
// The fallback block may bring the AP back after a later disconnect; its clients dropped with it without
|
||||
// a disconnect event for each
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
}
|
||||
|
||||
void WiFiComponent::set_ap(const WiFiAP &ap) {
|
||||
@@ -1526,12 +1503,14 @@ void WiFiComponent::disable() {
|
||||
this->stop_dpp_listen_();
|
||||
#endif
|
||||
this->wifi_disconnect_();
|
||||
#ifdef USE_WIFI_AP
|
||||
// The AP goes down with the radio; enable() has to configure it, and its portal, again
|
||||
this->forget_ap_();
|
||||
#endif
|
||||
this->wifi_mode_(false, false);
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
#ifdef USE_WIFI_AP
|
||||
// The AP went down with the radio; enable() has to configure it, and its portal, again
|
||||
this->end_ap_portal_();
|
||||
this->ap_setup_ = false;
|
||||
this->ap_clients_ = 0;
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
this->ap_changed_ = 0; // at once, not after ap_timeout
|
||||
#endif
|
||||
}
|
||||
@@ -1830,13 +1809,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
// Reset to initial phase on successful connection (don't log transition, just reset state)
|
||||
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
|
||||
this->num_retried_ = 0;
|
||||
#ifdef WIFI_AP_HAS_PORTAL
|
||||
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
|
||||
if (this->has_ap()) {
|
||||
#ifdef USE_WIFI_AP_PERSISTENT
|
||||
this->end_ap_portal_(); // the AP stays up for its clients
|
||||
#else
|
||||
this->disable_ap_();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_WIFI_DPP
|
||||
@@ -2477,23 +2452,8 @@ void WiFiComponent::start_ap_portal_() {
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->start_captive();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
this->apply_ap_dns_(); // point the clients at the portal; a failed refresh takes the AP down for loop() to retry
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
void WiFiComponent::start_portal_scan_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiComponent::end_ap_portal_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
if (this->is_captive_portal_active_())
|
||||
@@ -2502,10 +2462,6 @@ void WiFiComponent::end_ap_portal_() {
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->end_captive();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (this->ap_setup_ && this->ap_offers_own_dns_)
|
||||
this->apply_ap_dns_(); // hand the clients the upstream's resolver again
|
||||
#endif
|
||||
}
|
||||
bool WiFiComponent::is_improv_ble_active_() {
|
||||
#ifdef USE_IMPROV_BLE
|
||||
|
||||
@@ -460,7 +460,6 @@ class WiFiPowerSaveListener {
|
||||
// Scheduler ids
|
||||
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
|
||||
static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1;
|
||||
static constexpr uint32_t AP_DEAUTH_RETRY_TIMEOUT_ID = 2;
|
||||
|
||||
/// This component is responsible for managing the ESP WiFi interface.
|
||||
class WiFiComponent final : public Component {
|
||||
@@ -740,8 +739,6 @@ class WiFiComponent final : public Component {
|
||||
void setup_ap_config_();
|
||||
/// End the portals and turn the AP off.
|
||||
void disable_ap_();
|
||||
/// End the portals and forget the AP's state, before the radio takes the AP down.
|
||||
void forget_ap_();
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
|
||||
void pause_exclusive_ap_();
|
||||
@@ -753,16 +750,11 @@ class WiFiComponent final : public Component {
|
||||
bool ap_dns_known_();
|
||||
/// Hand the AP DHCP server its resolvers; false when one could not be set
|
||||
bool write_ap_dns_();
|
||||
/// Take the bridge down so loop() runs its setup again after ap_timeout
|
||||
void fail_bridge_();
|
||||
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
/// Pass the station's resolvers on to AP clients when they changed
|
||||
void apply_ap_dns_();
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
/// Disconnect the AP clients so they rejoin with the resolver now offered; a few retries, then the AP restarts
|
||||
void drop_ap_clients_(uint8_t attempt = 0);
|
||||
/// DHCP offers the AP's own address while the portal is up for a station that is down, or while the
|
||||
/// upstream has no resolver, so lookups reach the portal
|
||||
bool offer_own_dns_();
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -891,10 +883,6 @@ class WiFiComponent final : public Component {
|
||||
bool is_ap_portal_active_();
|
||||
void start_ap_portal_();
|
||||
void end_ap_portal_();
|
||||
#ifdef USE_WIFI_AP
|
||||
/// Scan alongside the AP so the portal can list networks, when there is no station to try
|
||||
void start_portal_scan_();
|
||||
#endif
|
||||
bool is_improv_ble_active_();
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
@@ -1042,7 +1030,7 @@ class WiFiComponent final : public Component {
|
||||
float output_power_{NAN};
|
||||
uint32_t action_started_;
|
||||
uint32_t last_connected_{0};
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
|
||||
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
|
||||
uint32_t ap_changed_{0}; // When the AP was last started or paused
|
||||
#endif
|
||||
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
|
||||
@@ -1161,10 +1149,6 @@ class WiFiComponent final : public Component {
|
||||
// Fallback AP starts that failed since the last connection; each one waits another ap_timeout
|
||||
uint8_t ap_start_failures_{0};
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
// DHCP offers the AP's own address; apply_ap_dns_() drops the clients whenever that changes
|
||||
bool ap_offers_own_dns_{false};
|
||||
#endif
|
||||
bool passive_scan_{false};
|
||||
bool has_saved_wifi_settings_{false};
|
||||
|
||||
@@ -1263,7 +1263,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
// the AP goes down and its setup runs again, which brings it back up through this event.
|
||||
if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err));
|
||||
this->disable_ap_();
|
||||
this->fail_bridge_();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1471,8 +1471,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP
|
||||
// Offer resolvers through DHCP: the configured ones, otherwise the station's (with a portal, the AP's own
|
||||
// address while there is none). A failure fails the setup and it is tried again.
|
||||
// Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would
|
||||
// offer the AP's own address, so a failure fails the setup and it is tried again.
|
||||
uint8_t dns_offer = 1;
|
||||
err =
|
||||
esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer));
|
||||
@@ -1652,7 +1652,7 @@ static constexpr uint32_t AP_DNS[] = {
|
||||
};
|
||||
#endif
|
||||
|
||||
static uint32_t upstream_dns(size_t i) {
|
||||
static uint32_t ap_dns_to_offer(size_t i) {
|
||||
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
|
||||
#else
|
||||
@@ -1660,45 +1660,14 @@ static uint32_t upstream_dns(size_t i) {
|
||||
#endif
|
||||
}
|
||||
|
||||
static uint32_t ap_dns_to_offer(size_t i, [[maybe_unused]] bool own) {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (own) {
|
||||
esp_netif_ip_info_t ip{};
|
||||
if (s_ap_netif != nullptr && esp_netif_get_ip_info(s_ap_netif, &ip) == ESP_OK)
|
||||
return ip.ip.addr;
|
||||
ESP_LOGW(TAG, "Failed to read the AP address to offer as resolver");
|
||||
return 0;
|
||||
}
|
||||
// A slot the upstream leaves empty repeats the primary, so the AP's own address is replaced there too
|
||||
const uint32_t addr = upstream_dns(i);
|
||||
return addr != 0 ? addr : upstream_dns(0);
|
||||
#else
|
||||
return upstream_dns(i);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WiFiComponent::ap_dns_known_() { return upstream_dns(0) != 0; }
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
bool WiFiComponent::offer_own_dns_() {
|
||||
return !this->ap_dns_known_() || (this->has_sta() && this->is_ap_portal_active_());
|
||||
}
|
||||
#endif
|
||||
bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
|
||||
|
||||
bool WiFiComponent::write_ap_dns_() {
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
const bool own = this->offer_own_dns_();
|
||||
this->ap_offers_own_dns_ = own;
|
||||
#else
|
||||
const bool own = false;
|
||||
#endif
|
||||
bool ok = true;
|
||||
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
|
||||
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||
if (addr == 0) {
|
||||
ok = ok && !own; // the AP's own address could not be read
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
if (addr == 0)
|
||||
continue;
|
||||
}
|
||||
esp_netif_dns_info_t info{};
|
||||
info.ip.type = ESP_IPADDR_TYPE_V4;
|
||||
info.ip.u_addr.ip4.addr = addr;
|
||||
@@ -1716,65 +1685,28 @@ bool WiFiComponent::write_ap_dns_() {
|
||||
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
|
||||
void WiFiComponent::apply_ap_dns_() {
|
||||
if (!this->ap_setup_)
|
||||
return; // loop() brings the AP up, and its setup writes the resolvers
|
||||
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
|
||||
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
|
||||
// next lease. A resolver the station lost stays offered (set_dns_info rejects an empty address), or with a
|
||||
// portal gives way to the AP's own address.
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
const bool own = this->offer_own_dns_();
|
||||
#else
|
||||
const bool own = false;
|
||||
#endif
|
||||
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
|
||||
bool changed = false;
|
||||
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
|
||||
const uint32_t addr = ap_dns_to_offer(i, own);
|
||||
const uint32_t addr = ap_dns_to_offer(i);
|
||||
esp_netif_dns_info_t current{};
|
||||
// A missing own address goes through the setup too, which then fails and restarts the AP
|
||||
changed =
|
||||
(addr == 0 && own) ||
|
||||
(addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), ¤t) != ESP_OK ||
|
||||
current.ip.u_addr.ip4.addr != addr));
|
||||
addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), ¤t) != ESP_OK ||
|
||||
current.ip.u_addr.ip4.addr != addr);
|
||||
}
|
||||
if (!changed)
|
||||
return;
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
// Clients rejoin whenever the offer switches between the AP's own address and the upstream's resolver
|
||||
const bool offered_own = this->ap_offers_own_dns_;
|
||||
#endif
|
||||
// The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
|
||||
#else
|
||||
const bool ok = this->wifi_ap_ip_config_({});
|
||||
#endif
|
||||
if (!ok) {
|
||||
this->disable_ap_();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
if (offered_own != this->ap_offers_own_dns_)
|
||||
this->drop_ap_clients_();
|
||||
#endif
|
||||
if (!ok)
|
||||
this->fail_bridge_();
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP_WISP_PORTAL
|
||||
void WiFiComponent::drop_ap_clients_(uint8_t attempt) {
|
||||
static constexpr uint8_t MAX_ATTEMPTS = 5;
|
||||
if (!this->ap_setup_)
|
||||
return;
|
||||
esp_err_t err = esp_wifi_deauth_sta(0);
|
||||
if (err == ESP_OK)
|
||||
return;
|
||||
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; restarting AP",
|
||||
esp_err_to_name(err));
|
||||
this->disable_ap_(); // loop() starts it again, and the clients rejoin
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; retrying", esp_err_to_name(err));
|
||||
this->set_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID, 1000, [this, attempt]() { this->drop_ap_clients_(attempt + 1); });
|
||||
}
|
||||
#endif
|
||||
#endif // USE_WIFI_AP_WISP_STATIC_DNS
|
||||
#endif // USE_WIFI_AP_WISP
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ _CALLBACK_AUTOMATIONS = (
|
||||
)
|
||||
|
||||
|
||||
# Above safe_mode, so Bluetooth OTA can still recover a device stuck in a boot loop
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
zephyr_add_prj_conf("BT", True)
|
||||
@@ -95,9 +93,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
zephyr_add_prj_conf("BT_SMP_SC_ONLY", True)
|
||||
zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True)
|
||||
request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum
|
||||
# Pairing confirmation is the user's automation, so in safe mode new pairings
|
||||
# are rejected (ble_server.cpp) and only bonded hosts can connect
|
||||
await cg.past_safe_mode()
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -181,14 +181,7 @@ void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) {
|
||||
snprintk(passkey_str, 7, "%06u", passkey);
|
||||
|
||||
ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str);
|
||||
global_ble_server->defer([passkey]() {
|
||||
if (global_ble_server->passkey_cb_.size() == 0) {
|
||||
ESP_LOGW(TAG, "Nothing answers numeric comparison (safe mode skips the automation), rejecting pairing");
|
||||
global_ble_server->numeric_comparison_reply(false);
|
||||
return;
|
||||
}
|
||||
global_ble_server->passkey_cb_(passkey);
|
||||
});
|
||||
global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); });
|
||||
}
|
||||
|
||||
static void auth_pairing_confirm(bt_conn *conn) {
|
||||
|
||||
@@ -78,9 +78,6 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
@@ -89,7 +86,6 @@ from esphome.core import (
|
||||
CORE,
|
||||
HexInt,
|
||||
Lambda,
|
||||
TemperatureWithUnit,
|
||||
TimePeriod,
|
||||
TimePeriodMicroseconds,
|
||||
TimePeriodMilliseconds,
|
||||
@@ -98,7 +94,6 @@ from esphome.core import (
|
||||
TimePeriodSeconds,
|
||||
Version,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||
from esphome.helpers import (
|
||||
@@ -1525,7 +1520,7 @@ METRIC_SUFFIXES = {
|
||||
|
||||
def float_with_unit(quantity, regex_suffix, optional_unit=False):
|
||||
pattern = re.compile(
|
||||
f"^([-+]?(?:[0-9]+\\.?[0-9]*|\\.[0-9]+))\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE
|
||||
)
|
||||
|
||||
def validator(value):
|
||||
@@ -1562,73 +1557,53 @@ distance = float_with_unit("distance", "(m)")
|
||||
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
|
||||
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
|
||||
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
|
||||
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
|
||||
_temperature_k = float_with_unit("temperature", "(°K|° K|K)")
|
||||
_temperature_f = float_with_unit("temperature", "(°F|° F|F)")
|
||||
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
|
||||
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
|
||||
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
|
||||
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
|
||||
|
||||
|
||||
class TemperatureUnitValue(str):
|
||||
"""A unit from temperature_unit(): the platform took the option, so the entity reports in it."""
|
||||
def temperature(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
|
||||
__slots__ = ()
|
||||
try:
|
||||
kelvin = _temperature_k(value)
|
||||
return kelvin - 273.15
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
try:
|
||||
fahrenheit = _temperature_f(value)
|
||||
return (fahrenheit - 32) * (5 / 9)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
raise err
|
||||
|
||||
|
||||
def temperature_unit(value: Any) -> TemperatureUnitValue:
|
||||
"""Validate the unit an entity reports its temperatures in."""
|
||||
return TemperatureUnitValue(one_of(*TEMPERATURE_UNITS)(value))
|
||||
def temperature_delta(value):
|
||||
err = None
|
||||
try:
|
||||
return _temperature_c(value)
|
||||
except Invalid as orig_err:
|
||||
err = orig_err
|
||||
|
||||
try:
|
||||
return _temperature_k(value)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
def temperature_with_unit(value: Any) -> TemperatureWithUnit:
|
||||
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
|
||||
for validator, unit in (
|
||||
(_temperature_k, UNIT_KELVIN),
|
||||
(_temperature_f, UNIT_FAHRENHEIT),
|
||||
(_temperature_c_strict, UNIT_CELSIUS),
|
||||
):
|
||||
try:
|
||||
return TemperatureWithUnit(validator(value), unit)
|
||||
except Invalid:
|
||||
pass
|
||||
return TemperatureWithUnit(_temperature_c(value), None)
|
||||
try:
|
||||
fahrenheit = _temperature_f(value)
|
||||
return fahrenheit * (5 / 9)
|
||||
except Invalid:
|
||||
pass
|
||||
|
||||
|
||||
# unit = celsius * scale + offset
|
||||
_TEMPERATURE_SCALES = {
|
||||
UNIT_CELSIUS: (1.0, 0.0),
|
||||
UNIT_FAHRENHEIT: (9 / 5, 32.0),
|
||||
UNIT_KELVIN: (1.0, 273.15),
|
||||
}
|
||||
|
||||
|
||||
templatable_temperature = templatable(temperature_with_unit)
|
||||
|
||||
|
||||
def convert_temperature(
|
||||
value: float, unit: str | None, to_unit: str, *, delta: bool = False
|
||||
) -> float:
|
||||
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
|
||||
|
||||
A value written without a unit is taken to be in ``to_unit`` already.
|
||||
"""
|
||||
if unit is None or unit == to_unit:
|
||||
return value
|
||||
scale, offset = _TEMPERATURE_SCALES[unit]
|
||||
to_scale, to_offset = _TEMPERATURE_SCALES[to_unit]
|
||||
if delta:
|
||||
return value / scale * to_scale
|
||||
return (value - offset) / scale * to_scale + to_offset
|
||||
|
||||
|
||||
def temperature(value: Any) -> float:
|
||||
"""Parse a temperature in °C, K or °F into degrees Celsius."""
|
||||
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
|
||||
|
||||
|
||||
def temperature_delta(value: Any) -> float:
|
||||
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
|
||||
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
|
||||
raise err
|
||||
|
||||
|
||||
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
|
||||
|
||||
@@ -1269,7 +1269,6 @@ UNIT_DECIBEL_MILLIWATT = "dBm"
|
||||
UNIT_DEGREE_PER_SECOND = "°/s"
|
||||
UNIT_DEGREES = "°"
|
||||
UNIT_EMPTY = ""
|
||||
UNIT_FAHRENHEIT = "°F"
|
||||
UNIT_G = "G"
|
||||
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
|
||||
UNIT_HECTOPASCAL = "hPa"
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_BUILD_PATH,
|
||||
@@ -100,16 +100,6 @@ def is_approximately_integer(value):
|
||||
return abs(value - round(value)) < 0.001
|
||||
|
||||
|
||||
class TemperatureWithUnit(NamedTuple):
|
||||
"""A temperature as written: the number and the unit it came with, None without one."""
|
||||
|
||||
value: float
|
||||
unit: str | None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.value}{self.unit or ''}"
|
||||
|
||||
|
||||
class TimePeriod:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -381,7 +381,6 @@
|
||||
#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host
|
||||
#define USE_MQTT
|
||||
#define USE_MQTT_ABBREVIATIONS
|
||||
#define USE_MQTT_SESSION_PERSISTENCE
|
||||
#define USE_MQTT_COVER_JSON
|
||||
#define USE_WIREGUARD
|
||||
#endif
|
||||
@@ -555,9 +554,10 @@
|
||||
#define USE_WIFI_FAST_CONNECT_IN_FLASH
|
||||
#define USE_WIFI_LEASE_CACHE
|
||||
#define USE_WIFI_LEASE_CACHE_IN_FLASH
|
||||
#define USE_WIFI_AP_PERSISTENT
|
||||
#define USE_WIFI_AP_WISP
|
||||
#define USE_WIFI_AP_WISP_PORTAL
|
||||
#define USE_WIFI_AP_WISP_STATIC_DNS
|
||||
#define WIFI_AP_WISP_DNS_0 0x08080808
|
||||
#define WIFI_AP_WISP_DNS_1 0x04040808
|
||||
#define USE_WIFI_PHY_MODE
|
||||
#define USE_WIFI_IP_STATE_LISTENERS
|
||||
#define USE_WIFI_SCAN_RESULTS_LISTENERS
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import functools
|
||||
from importlib import import_module
|
||||
@@ -20,23 +20,18 @@ from esphome.const import (
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_MQTT_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_REPORT,
|
||||
CONF_SORTING_GROUP_ID,
|
||||
CONF_SORTING_WEIGHT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USE_DEVICE_TYPE,
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WEB_SERVER_ID,
|
||||
CONF_ZIGBEE_BINARY_SENSOR,
|
||||
CONF_ZIGBEE_ID,
|
||||
CONF_ZIGBEE_SENSOR,
|
||||
UNIT_CELSIUS,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.config import (
|
||||
@@ -52,7 +47,7 @@ from esphome.cpp_generator import (
|
||||
add,
|
||||
get_variable,
|
||||
)
|
||||
from esphome.cpp_types import TEMPERATURE_UNITS, App
|
||||
from esphome.cpp_types import App
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import (
|
||||
cpp_string_escape,
|
||||
@@ -61,7 +56,6 @@ from esphome.helpers import (
|
||||
sanitize,
|
||||
snake_case,
|
||||
)
|
||||
from esphome.loader import get_component, get_platform
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
||||
|
||||
@@ -627,6 +621,12 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
||||
[CONF_SENSOR, 1, CONF_POWER_ID].
|
||||
"""
|
||||
|
||||
def _walk_config(config, path):
|
||||
walk = [path] if not isinstance(path, list) else path
|
||||
for item_or_index in walk:
|
||||
config = config[item_or_index]
|
||||
return config
|
||||
|
||||
def inherit_property(config):
|
||||
# Split the property into its path and name
|
||||
if not isinstance(property_to_inherit, list):
|
||||
@@ -662,171 +662,9 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
|
||||
|
||||
return config
|
||||
|
||||
inherit_property.inherits = (property_to_inherit, parent_id_property, transform)
|
||||
return inherit_property
|
||||
|
||||
|
||||
def _walk_config(config: ConfigType, path: str | list) -> Any:
|
||||
for item_or_index in path if isinstance(path, list) else [path]:
|
||||
config = config[item_or_index]
|
||||
return config
|
||||
|
||||
|
||||
def _inherit_validators(validator: Any) -> Iterator[Callable]:
|
||||
"""The inherit_property_from validators in a FINAL_VALIDATE_SCHEMA, also inside cv.All."""
|
||||
if hasattr(validator, "inherits"):
|
||||
yield validator
|
||||
for inner in getattr(validator, "validators", ()):
|
||||
yield from _inherit_validators(inner)
|
||||
|
||||
|
||||
def _inherits(manifest: Any, property_: str) -> tuple | None:
|
||||
"""What ``inherit_property_from`` gives ``property_`` in a component's final validation."""
|
||||
validators = _inherit_validators(manifest.final_validate_schema)
|
||||
return next((v.inherits for v in validators if v.inherits[0] == property_), None)
|
||||
|
||||
|
||||
# Returned by resolve_inherited_property when the chain cannot be followed
|
||||
UNRESOLVED_PROPERTY = object()
|
||||
|
||||
|
||||
def resolve_inherited_property(entity_id: ID, property_: str) -> Any:
|
||||
"""The value of ``property_`` for the entity that declares ``entity_id``, following the
|
||||
``inherit_property_from`` chain (a copy of a copy) the way final validation fills it in, so
|
||||
the result does not depend on which component validates first. None when it is not set;
|
||||
``UNRESOLVED_PROPERTY`` when the chain cannot be followed (a cycle, an unknown component or
|
||||
an entity nested under a component that is not a platform)."""
|
||||
fconf = fv.full_config.get()
|
||||
seen: set[ID] = set()
|
||||
steps: list[tuple[Callable | None, ConfigType]] = []
|
||||
value = None
|
||||
while entity_id not in seen:
|
||||
seen.add(entity_id)
|
||||
path = fconf.get_path_for_id(entity_id)[:-1]
|
||||
config = fconf.get_config_for_path(path)
|
||||
if property_ in config:
|
||||
value = config[property_]
|
||||
break
|
||||
if (platform := config.get(CONF_PLATFORM)) is not None:
|
||||
manifest = get_platform(path[0], platform)
|
||||
elif len(path) <= 2:
|
||||
manifest = get_component(path[0])
|
||||
else:
|
||||
return UNRESOLVED_PROPERTY
|
||||
if manifest is None:
|
||||
return UNRESOLVED_PROPERTY
|
||||
if (inherits := _inherits(manifest, property_)) is None:
|
||||
return None
|
||||
_, parent_id_property, transform = inherits
|
||||
steps.append((transform, config))
|
||||
entity_id = _walk_config(config, parent_id_property)
|
||||
else:
|
||||
return UNRESOLVED_PROPERTY
|
||||
for transform, config in reversed(steps):
|
||||
if transform:
|
||||
value = transform(value, config)
|
||||
return value
|
||||
|
||||
|
||||
# Takes the entity config and the full config, and returns the unit the entity reports in
|
||||
TemperatureUnitResolver = Callable[[ConfigType, ConfigType], str]
|
||||
|
||||
# Platforms whose entities report in one unit only, set by their traits, or a resolver for those
|
||||
# whose unit follows their own (or their hub's) config; keyed by the C++ class name
|
||||
_FIXED_TEMPERATURE_UNITS: dict[str, str | TemperatureUnitResolver] = {}
|
||||
|
||||
|
||||
def declare_fixed_temperature_unit(
|
||||
class_: MockObjClass, unit: str | TemperatureUnitResolver
|
||||
) -> None:
|
||||
"""Record the unit every entity of ``class_`` reports in, or a resolver that reads it from the
|
||||
config; nothing is stored in the config, so the unit never appears in a config dump."""
|
||||
_FIXED_TEMPERATURE_UNITS[str(class_)] = unit
|
||||
|
||||
|
||||
def _declared_temperature_unit(type_: Any) -> str | TemperatureUnitResolver:
|
||||
"""The unit declared for ``type_`` or its closest declared parent class, else Celsius, so a
|
||||
platform built on a declared class (an external component) reports in the same unit."""
|
||||
classes = [type_, *type_.parents] if isinstance(type_, MockObjClass) else [type_]
|
||||
for class_ in classes:
|
||||
if (declared := _FIXED_TEMPERATURE_UNITS.get(str(class_))) is not None:
|
||||
return declared
|
||||
return UNIT_CELSIUS
|
||||
|
||||
|
||||
def entity_temperature_unit(
|
||||
config: ConfigType, full_config: ConfigType | None = None
|
||||
) -> str | None:
|
||||
"""The unit a climate or water heater reports in: ``unit_of_measurement`` when the platform took
|
||||
the option through its schema factory, the platform's fixed or resolved unit when it declared
|
||||
one, else Celsius (anova's option of that name is unrelated). A resolver needs ``full_config``;
|
||||
without it the unit is not known yet and this returns None."""
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
if isinstance(unit, cv.TemperatureUnitValue):
|
||||
return unit
|
||||
entity_id = config.get(CONF_ID)
|
||||
declared = _declared_temperature_unit(
|
||||
entity_id.type if entity_id is not None else None
|
||||
)
|
||||
if isinstance(declared, str):
|
||||
return declared
|
||||
if full_config is None:
|
||||
return None
|
||||
return declared(config, full_config)
|
||||
|
||||
|
||||
def entity_temperature_unit_of_id(entity_id: ID) -> str:
|
||||
"""The unit of the entity with this id, for actions that take static temperatures."""
|
||||
return entity_temperature_unit(
|
||||
fv.get_declaring_config(entity_id, CORE.config), CORE.config
|
||||
)
|
||||
|
||||
|
||||
def convert_temperature_keys(
|
||||
section: ConfigType, keys: tuple[str, ...], unit: str, *, delta: bool = False
|
||||
) -> None:
|
||||
"""Convert the ``(value, unit)`` temperatures under ``keys`` into ``unit``; absent or already
|
||||
converted values are left alone."""
|
||||
for key in keys:
|
||||
if isinstance(value := section.get(key), tuple):
|
||||
section[key] = cv.convert_temperature(*value, unit, delta=delta)
|
||||
|
||||
|
||||
def convert_visual_temperatures(
|
||||
config: ConfigType,
|
||||
delta_keys: tuple[str, ...],
|
||||
delta_section: str | None = None,
|
||||
full_config: ConfigType | None = None,
|
||||
) -> ConfigType:
|
||||
"""Convert a climate's or water heater's ``visual`` temperatures into the entity's unit. The
|
||||
step keys are differences, found under ``delta_section`` when the domain nests them. A unit
|
||||
that needs ``full_config`` is left for final validation, which runs this again with it."""
|
||||
if (unit := entity_temperature_unit(config, full_config)) is None:
|
||||
return config
|
||||
visual = config[CONF_VISUAL]
|
||||
convert_temperature_keys(visual, (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE), unit)
|
||||
section = visual if delta_section is None else visual.get(delta_section)
|
||||
if section is not None:
|
||||
convert_temperature_keys(section, delta_keys, unit, delta=True)
|
||||
return config
|
||||
|
||||
|
||||
def temperature_in_entity_unit(
|
||||
config: ConfigType, value: tuple[float, str | None]
|
||||
) -> str:
|
||||
"""Render a static action temperature in the unit of the entity the action targets."""
|
||||
unit = entity_temperature_unit_of_id(config[CONF_ID])
|
||||
return str(cg.safe_exp(cv.convert_temperature(*value, unit)))
|
||||
|
||||
|
||||
def setup_entity_temperature_unit(var: MockObj, config: ConfigType) -> None:
|
||||
"""Pass the unit an entity reports in to its platform, which took the option; a platform with a
|
||||
fixed unit sets it in its own traits."""
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
if isinstance(unit, cv.TemperatureUnitValue):
|
||||
add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
|
||||
|
||||
|
||||
def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Create a validator function to check for duplicate entity names.
|
||||
|
||||
|
||||
@@ -1809,15 +1809,6 @@ enum class TemperatureUnit : uint8_t {
|
||||
KELVIN = 2,
|
||||
};
|
||||
|
||||
/// Convert degrees Celsius into the given unit.
|
||||
constexpr float celsius_to(float value, TemperatureUnit unit) {
|
||||
if (unit == TemperatureUnit::FAHRENHEIT)
|
||||
return celsius_to_fahrenheit(value);
|
||||
if (unit == TemperatureUnit::KELVIN)
|
||||
return value + 273.15f;
|
||||
return value;
|
||||
}
|
||||
|
||||
///@}
|
||||
|
||||
/// @name Utilities
|
||||
|
||||
@@ -1331,11 +1331,6 @@ class MockObjClass(MockObj):
|
||||
# pylint: disable=protected-access
|
||||
self._parents += paren._parents
|
||||
|
||||
@property
|
||||
def parents(self) -> list["MockObjClass"]:
|
||||
"""Every ancestor class: each direct parent followed by its own parents."""
|
||||
return self._parents
|
||||
|
||||
def inherits_from(self, other: "MockObjClass") -> bool:
|
||||
if str(self) == str(other):
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.const import UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
global_ns = MockObj("", "")
|
||||
@@ -43,12 +42,6 @@ InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin)
|
||||
gpio_ns = esphome_ns.namespace("gpio")
|
||||
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
|
||||
EntityCategory = esphome_ns.enum("EntityCategory")
|
||||
TemperatureUnit = esphome_ns.enum("TemperatureUnit", is_class=True)
|
||||
TEMPERATURE_UNITS = {
|
||||
UNIT_CELSIUS: TemperatureUnit.CELSIUS,
|
||||
UNIT_FAHRENHEIT: TemperatureUnit.FAHRENHEIT,
|
||||
UNIT_KELVIN: TemperatureUnit.KELVIN,
|
||||
}
|
||||
Parented = esphome_ns.class_("Parented")
|
||||
ESPTime = esphome_ns.struct("ESPTime")
|
||||
StringRef = esphome_ns.class_("StringRef")
|
||||
@@ -34,7 +34,6 @@ from esphome.core import (
|
||||
EsphomeError,
|
||||
Lambda,
|
||||
MACAddress,
|
||||
TemperatureWithUnit,
|
||||
TimePeriod,
|
||||
)
|
||||
from esphome.expression import has_substitution_or_expression
|
||||
@@ -1632,10 +1631,6 @@ ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_string
|
||||
ESPHomeDumper.add_multi_representer(_BaseNetwork, ESPHomeDumper.represent_stringify)
|
||||
ESPHomeDumper.add_multi_representer(MACAddress, ESPHomeDumper.represent_stringify)
|
||||
ESPHomeDumper.add_multi_representer(TimePeriod, ESPHomeDumper.represent_stringify)
|
||||
# Dumped as written, unit included, so `esphome config` output validates again
|
||||
ESPHomeDumper.add_multi_representer(
|
||||
TemperatureWithUnit, ESPHomeDumper.represent_stringify
|
||||
)
|
||||
ESPHomeDumper.add_multi_representer(Lambda, ESPHomeDumper.represent_lambda)
|
||||
ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend)
|
||||
ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove)
|
||||
|
||||
Whitespace-only changes.
@@ -1,13 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
logger:
|
||||
|
||||
zephyr_ble_server:
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
@@ -1,22 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
logger:
|
||||
|
||||
safe_mode:
|
||||
|
||||
zephyr_ble_server:
|
||||
on_numeric_comparison_request:
|
||||
then:
|
||||
- ble_server.numeric_comparison_reply:
|
||||
accept: true
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
on_begin:
|
||||
then:
|
||||
- logger.log: "OTA begin"
|
||||
@@ -1,9 +0,0 @@
|
||||
esphome:
|
||||
name: adafruitbletest
|
||||
|
||||
nrf52:
|
||||
board: adafruit_itsybitsy_nrf52840
|
||||
bootloader: adafruit_nrf52_sd140_v6
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests for the adafruit_ble OTA platform and its nrf52 integration."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.adafruit_ble.ota import CONFIG_SCHEMA, _final_validate
|
||||
from esphome.components.nrf52 import _final_validate as nrf52_final_validate
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V6
|
||||
from esphome.components.zephyr.const import (
|
||||
BOOTLOADER_MCUBOOT,
|
||||
KEY_BOOTLOADER,
|
||||
KEY_ZEPHYR,
|
||||
)
|
||||
from esphome.config import Config, read_config
|
||||
from esphome.const import (
|
||||
CONF_ADVANCED,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_OTA_ROLLBACK,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_OTA,
|
||||
CONF_PLATFORM,
|
||||
CONF_SAFE_MODE,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
NRF52_CONFIG = {
|
||||
KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
|
||||
CONF_FRAMEWORK: {CONF_ADVANCED: {}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adafruit_ble_ota_full_config() -> Generator[None]:
|
||||
"""Full config as final validation sees it with adafruit_ble OTA configured."""
|
||||
full = Config()
|
||||
full[CONF_OTA] = [{CONF_PLATFORM: "adafruit_ble"}]
|
||||
full[CONF_SAFE_MODE] = {CONF_DISABLED: False}
|
||||
token = fv.full_config.set(full)
|
||||
yield
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_adafruit_ble_config_builds_ota_component(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("adafruit_ble.yaml"))
|
||||
assert "adafruit_ble::OTAComponent" in main_cpp
|
||||
|
||||
|
||||
def test_requires_zephyr_ble_server(
|
||||
component_config_path: Callable[[str], Path], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
CORE.config_path = component_config_path("adafruit_ble_without_server.yaml")
|
||||
assert read_config({}) is None
|
||||
assert "requires component zephyr_ble_server" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_requires_adafruit_bootloader() -> None:
|
||||
CORE.data[KEY_ZEPHYR] = {KEY_BOOTLOADER: BOOTLOADER_MCUBOOT}
|
||||
with pytest.raises(cv.Invalid, match="requires an Adafruit bootloader"):
|
||||
_final_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("server", "warned"),
|
||||
[
|
||||
({}, True),
|
||||
({"on_numeric_comparison_request": [{"then": []}]}, False),
|
||||
],
|
||||
ids=["no_pairing", "pairing"],
|
||||
)
|
||||
def test_warns_about_open_trigger_without_pairing(
|
||||
adafruit_ble_ota_full_config: None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
server: dict,
|
||||
warned: bool,
|
||||
) -> None:
|
||||
CORE.data[KEY_ZEPHYR] = {KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT_NRF52_SD140_V6}
|
||||
fv.full_config.get()["zephyr_ble_server"] = server
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate({})
|
||||
assert ("without pairing" in caplog.text) is warned
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["on_progress", "on_end", "on_error", "on_abort"])
|
||||
def test_rejects_triggers_that_never_fire(key: str) -> None:
|
||||
schema = CONFIG_SCHEMA.validators[0] # Skips the framework check
|
||||
with pytest.raises(cv.Invalid, match=f"'{key}' never fires"):
|
||||
schema({key: [{"then": []}]})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"drop",
|
||||
[None, CONF_SAFE_MODE, CONF_OTA],
|
||||
ids=["ota_and_safe_mode", "without_safe_mode", "without_ota"],
|
||||
)
|
||||
def test_rollback_ignored_when_enabled_by_user(
|
||||
adafruit_ble_ota_full_config: None,
|
||||
drop: str | None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An explicit request without MCUboot is turned off with a warning, so older configs still validate."""
|
||||
if drop is not None:
|
||||
del fv.full_config.get()[drop]
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = {"adafruit_ble"}
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] = True
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is False
|
||||
assert "requires MCUboot" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integrations", "expected"),
|
||||
[({"adafruit_ble"}, False), ({"zephyr_mcumgr"}, True)],
|
||||
ids=["adafruit_ble", "mcumgr_second_stage"],
|
||||
)
|
||||
def test_rollback_default_follows_mcuboot(
|
||||
adafruit_ble_ota_full_config: None, integrations: set[str], expected: bool
|
||||
) -> None:
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = integrations
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is expected
|
||||
|
||||
|
||||
def test_rollback_default_off_quietly_without_safe_mode(
|
||||
adafruit_ble_ota_full_config: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
del fv.full_config.get()[CONF_SAFE_MODE]
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
CORE.loaded_integrations = {"adafruit_ble"}
|
||||
config = deepcopy(NRF52_CONFIG)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
nrf52_final_validate(config)
|
||||
assert config[CONF_FRAMEWORK][CONF_ADVANCED][CONF_ENABLE_OTA_ROLLBACK] is False
|
||||
assert "rollback" not in caplog.text
|
||||
|
||||
|
||||
def test_safe_mode_keeps_the_ble_trigger(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Bluetooth and the trigger come up in safe mode; user automations do not."""
|
||||
main_cpp = generate_main(component_config_path("adafruit_ble_safe_mode.yaml"))
|
||||
guard = main_cpp.index("->should_enter_safe_mode(")
|
||||
for registration in (
|
||||
"App.register_component_(zephyr_ble_server_bleserver_id",
|
||||
"App.register_component_(adafruit_ble_otacomponent_id",
|
||||
):
|
||||
assert main_cpp.index(registration) < guard
|
||||
for automation in ("->add_passkey_callback(", "new(ota_otastarttrigger_id)"):
|
||||
assert main_cpp.index(automation) > guard
|
||||
@@ -1,48 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
- climate.control:
|
||||
id: cn105_ac
|
||||
target_temperature: 72°F
|
||||
- climate.control:
|
||||
id: friedrich_ac
|
||||
target_temperature: 20°C
|
||||
- climate.control:
|
||||
id: islandaire_ac
|
||||
target_temperature: 20°C
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
uart:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
baud_rate: 2400
|
||||
parity: EVEN
|
||||
|
||||
remote_transmitter:
|
||||
pin: GPIO5
|
||||
carrier_duty_percent: 50%
|
||||
|
||||
mitsubishi_cn105:
|
||||
id: hub
|
||||
use_fahrenheit: ${use_fahrenheit}
|
||||
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: cn105_ac
|
||||
name: CN105
|
||||
visual:
|
||||
min_temperature: 60°F
|
||||
max_temperature: 30°C
|
||||
- platform: friedrich
|
||||
id: friedrich_ac
|
||||
name: Friedrich
|
||||
visual:
|
||||
min_temperature: 15°C
|
||||
- platform: islandaire
|
||||
id: islandaire_ac
|
||||
name: Islandaire
|
||||
visual:
|
||||
min_temperature: 15°C
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Tests for the climate visual temperature conversion."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.climate import (
|
||||
Climate,
|
||||
_final_validate,
|
||||
_visual_temperatures_in_entity_unit,
|
||||
climate_ns,
|
||||
climate_schema,
|
||||
visual_temperature_step,
|
||||
)
|
||||
from esphome.config import Config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CURRENT_TEMPERATURE,
|
||||
CONF_ID,
|
||||
CONF_MAX_TEMPERATURE,
|
||||
CONF_MIN_TEMPERATURE,
|
||||
CONF_NAME,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VISUAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
)
|
||||
from esphome.core import CORE, ID, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_temperature_unit,
|
||||
entity_temperature_unit_of_id,
|
||||
setup_entity_temperature_unit,
|
||||
temperature_in_entity_unit,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
|
||||
def test_visual_temperature_step_scalar_applies_to_both() -> None:
|
||||
step = visual_temperature_step("1°F")
|
||||
assert step == {
|
||||
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
CONF_CURRENT_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
}
|
||||
|
||||
|
||||
def test_visual_temperatures_default_to_celsius() -> None:
|
||||
config = _visual_temperatures_in_entity_unit(
|
||||
{
|
||||
CONF_VISUAL: {
|
||||
CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT),
|
||||
CONF_MAX_TEMPERATURE: (30.0, None),
|
||||
CONF_TEMPERATURE_STEP: visual_temperature_step("1.8°F"),
|
||||
}
|
||||
}
|
||||
)
|
||||
visual = config[CONF_VISUAL]
|
||||
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
|
||||
assert visual[CONF_MAX_TEMPERATURE] == 30.0
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_visual_temperatures_follow_unit_of_measurement() -> None:
|
||||
config = _visual_temperatures_in_entity_unit(
|
||||
{
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT),
|
||||
CONF_VISUAL: {
|
||||
CONF_MIN_TEMPERATURE: (60.0, None),
|
||||
CONF_MAX_TEMPERATURE: (30.0, UNIT_CELSIUS),
|
||||
CONF_TEMPERATURE_STEP: {
|
||||
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
|
||||
CONF_CURRENT_TEMPERATURE: (0.5, UNIT_CELSIUS),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
visual = config[CONF_VISUAL]
|
||||
assert visual[CONF_MIN_TEMPERATURE] == 60.0
|
||||
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(86.0)
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
|
||||
assert visual[CONF_TEMPERATURE_STEP][CONF_CURRENT_TEMPERATURE] == pytest.approx(0.9)
|
||||
|
||||
|
||||
def test_only_a_validated_unit_is_the_entity_unit() -> None:
|
||||
# anova validates its own unit_of_measurement for the device; it is not the entity unit
|
||||
assert entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS
|
||||
assert (
|
||||
entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
|
||||
== UNIT_CELSIUS
|
||||
)
|
||||
assert (
|
||||
entity_temperature_unit(
|
||||
{CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT)}
|
||||
)
|
||||
== UNIT_FAHRENHEIT
|
||||
)
|
||||
|
||||
|
||||
def test_visual_temperatures_converted_once() -> None:
|
||||
config = {CONF_VISUAL: {CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT)}}
|
||||
_visual_temperatures_in_entity_unit(config)
|
||||
_visual_temperatures_in_entity_unit(config)
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_a_fixed_unit_keeps_visual_temperatures_in_it() -> None:
|
||||
# friedrich reports in °F through its traits; the unit is declared, not a user option
|
||||
fixed = climate_ns.class_("FixedUnitClimate", Climate)
|
||||
schema = climate_schema(fixed, fixed_temperature_unit=UNIT_FAHRENHEIT)
|
||||
config = schema({CONF_NAME: "AC", CONF_VISUAL: {CONF_TEMPERATURE_STEP: "1°F"}})
|
||||
assert entity_temperature_unit(config) == UNIT_FAHRENHEIT
|
||||
assert config[CONF_VISUAL][CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
|
||||
# Nothing is stored in the config, so a dumped config (without its generated id) validates again
|
||||
assert CONF_UNIT_OF_MEASUREMENT not in config
|
||||
again = schema(
|
||||
{key: value for key, value in config.items() if key != CONF_ID}
|
||||
| {CONF_NAME: "AC again"}
|
||||
)
|
||||
assert again[CONF_VISUAL] == config[CONF_VISUAL]
|
||||
with pytest.raises(cv.Invalid, match="unit_of_measurement"):
|
||||
schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"declared",
|
||||
[UNIT_FAHRENHEIT, lambda config, full: UNIT_FAHRENHEIT],
|
||||
ids=["fixed", "resolved"],
|
||||
)
|
||||
def test_a_subclass_reports_in_its_parents_declared_unit(
|
||||
declared: str | Callable[[dict, Config], str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An external platform built on a declared class (friedrich) keeps the parent's unit."""
|
||||
monkeypatch.setattr(entity_helpers, "_FIXED_TEMPERATURE_UNITS", {})
|
||||
parent = climate_ns.class_("DeclaredUnitClimate", Climate)
|
||||
climate_schema(parent, fixed_temperature_unit=declared)
|
||||
child = climate_ns.class_("ExternalClimate", parent)
|
||||
grandchild = climate_ns.class_("VendoredClimate", child)
|
||||
for class_ in (child, grandchild):
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=class_)}
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_FAHRENHEIT
|
||||
# The closest declaration wins
|
||||
climate_schema(child, fixed_temperature_unit=UNIT_KELVIN)
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=grandchild)}
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_KELVIN
|
||||
# An undeclared class still reports in Celsius
|
||||
unrelated = climate_ns.class_("UnrelatedClimate", Climate)
|
||||
config = {CONF_ID: ID("ac", is_declaration=True, type=unrelated)}
|
||||
assert entity_temperature_unit(config) == UNIT_CELSIUS
|
||||
|
||||
|
||||
def test_a_platform_cannot_take_the_option_and_fix_the_unit() -> None:
|
||||
with pytest.raises(ValueError, match="not both"):
|
||||
climate_schema(
|
||||
Climate, temperature_unit=True, fixed_temperature_unit=UNIT_FAHRENHEIT
|
||||
)
|
||||
|
||||
|
||||
def test_a_fixed_unit_is_not_passed_to_the_platform() -> None:
|
||||
calls: list[str] = []
|
||||
original_add = entity_helpers.add
|
||||
entity_helpers.add = lambda expression: calls.append(str(expression))
|
||||
try:
|
||||
var = MockObj("ac")
|
||||
fixed = climate_ns.class_("FixedUnitClimate", Climate)
|
||||
climate_schema(fixed, fixed_temperature_unit=UNIT_FAHRENHEIT)
|
||||
setup_entity_temperature_unit(
|
||||
var, {CONF_ID: ID("ac", is_declaration=True, type=fixed)}
|
||||
)
|
||||
assert calls == []
|
||||
setup_entity_temperature_unit(
|
||||
var, {CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit("°F")}
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].startswith("ac.set_temperature_unit(")
|
||||
assert calls[0].endswith("TemperatureUnit::FAHRENHEIT)")
|
||||
finally:
|
||||
entity_helpers.add = original_add
|
||||
|
||||
|
||||
def test_kelvin_is_rejected_with_mqtt() -> None:
|
||||
schema = climate_schema(Climate, temperature_unit=True)
|
||||
CORE.loaded_integrations.add("mqtt")
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="°C or °F"):
|
||||
schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_KELVIN})
|
||||
finally:
|
||||
CORE.loaded_integrations.discard("mqtt")
|
||||
config = schema({CONF_NAME: "AC", CONF_UNIT_OF_MEASUREMENT: UNIT_KELVIN})
|
||||
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KELVIN
|
||||
|
||||
|
||||
def test_action_temperatures_follow_the_target_entity_unit() -> None:
|
||||
ac = ID("ac", is_declaration=True, type="climate::Climate")
|
||||
full = Config()
|
||||
full["climate"] = [
|
||||
{CONF_ID: ac, CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT)}
|
||||
]
|
||||
full.declare_ids.append((ac, ["climate", 0, CONF_ID]))
|
||||
CORE.config = full
|
||||
|
||||
target = ID("ac", is_declaration=False)
|
||||
assert entity_temperature_unit_of_id(target) == UNIT_FAHRENHEIT
|
||||
assert temperature_in_entity_unit(
|
||||
{CONF_ID: target}, (20.0, UNIT_CELSIUS)
|
||||
).startswith("68.0")
|
||||
|
||||
|
||||
def test_a_resolved_unit_waits_for_the_full_config() -> None:
|
||||
resolved = climate_ns.class_("ResolvedUnitClimate", Climate)
|
||||
schema = climate_schema(
|
||||
resolved, fixed_temperature_unit=lambda config, full: UNIT_FAHRENHEIT
|
||||
)
|
||||
# The resolver needs the full config, so schema validation leaves the value as written
|
||||
config = schema({CONF_NAME: "AC", CONF_VISUAL: {CONF_MIN_TEMPERATURE: "15°C"}})
|
||||
assert entity_temperature_unit(config) is None
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == (15.0, UNIT_CELSIUS)
|
||||
assert entity_temperature_unit(config, Config()) == UNIT_FAHRENHEIT
|
||||
_final_validate([config])
|
||||
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(59.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("use_fahrenheit", "cn105_lines"),
|
||||
[
|
||||
(
|
||||
"true",
|
||||
(
|
||||
"cn105_ac->set_visual_min_temperature_override(60.0f);",
|
||||
"cn105_ac->set_visual_max_temperature_override(86.0f);",
|
||||
"apply_call.set_target_temperature(72.0f);",
|
||||
),
|
||||
),
|
||||
(
|
||||
"false",
|
||||
(
|
||||
"cn105_ac->set_visual_min_temperature_override(15.5555",
|
||||
"cn105_ac->set_visual_max_temperature_override(30.0f);",
|
||||
"apply_call.set_target_temperature(22.2222",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_platforms_reporting_in_fahrenheit_resolve_their_unit(
|
||||
use_fahrenheit: str,
|
||||
cn105_lines: tuple[str, ...],
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# mitsubishi_cn105 follows its hub's use_fahrenheit; friedrich and islandaire are always °F
|
||||
source = component_config_path("fahrenheit_platforms.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
config_path = tmp_path / "fahrenheit_platforms.yaml"
|
||||
config_path.write_text(
|
||||
source.replace("${use_fahrenheit}", use_fahrenheit), encoding="utf-8"
|
||||
)
|
||||
main_cpp = generate_main(config_path)
|
||||
for line in cn105_lines:
|
||||
assert line in main_cpp
|
||||
assert "friedrich_ac->set_visual_min_temperature_override(59.0f);" in main_cpp
|
||||
assert "islandaire_ac->set_visual_min_temperature_override(59.0f);" in main_cpp
|
||||
# friedrich and islandaire are each sent 20°C as 68°F
|
||||
assert main_cpp.count("apply_call.set_target_temperature(68.0f);") == 2
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test-device
|
||||
|
||||
esp8266:
|
||||
board: d1_mini_lite
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
mqtt:
|
||||
broker: 192.168.1.2
|
||||
clean_session: false
|
||||
persist_subscriptions: true
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Tests for the mqtt persist_subscriptions option."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp8266.const import KEY_ESP8266, KEY_PREFERENCES
|
||||
from esphome.components.mqtt import CONF_PERSIST_SUBSCRIPTIONS, validate_config
|
||||
from esphome.const import CONF_CLEAN_SESSION, CONF_TOPIC_PREFIX
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_rejected_with_clean_session() -> None:
|
||||
with pytest.raises(cv.Invalid, match="requires 'clean_session: false'"):
|
||||
validate_config(
|
||||
{
|
||||
CONF_TOPIC_PREFIX: "node",
|
||||
CONF_CLEAN_SESSION: True,
|
||||
CONF_PERSIST_SUBSCRIPTIONS: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_accepted_without_clean_session() -> None:
|
||||
out = validate_config(
|
||||
{
|
||||
CONF_TOPIC_PREFIX: "node",
|
||||
CONF_CLEAN_SESSION: False,
|
||||
CONF_PERSIST_SUBSCRIPTIONS: True,
|
||||
}
|
||||
)
|
||||
assert out[CONF_PERSIST_SUBSCRIPTIONS] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "enabled"),
|
||||
[("persist_subscriptions.yaml", True), ("no_mac_suffix.yaml", False)],
|
||||
)
|
||||
def test_define_and_rtc_reservation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
generate_main(component_config_path(config_file))
|
||||
assert ("USE_MQTT_SESSION_PERSISTENCE" in {d.name for d in CORE.defines}) is enabled
|
||||
# One uint32_t in ESP8266 RTC memory: a data word plus the checksum word, not in flash
|
||||
reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, [])
|
||||
assert reservations.count((2, False)) == int(enabled)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Validate RX8111 alarm and timer options before generating firmware."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.rx8111.time import (
|
||||
@@ -10,8 +8,6 @@ from esphome.components.rx8111.time import (
|
||||
CONF_ALARM_DAY,
|
||||
CONF_ALARM_ENABLED,
|
||||
CONF_ALARM_WEEKDAY,
|
||||
CONF_BATTERY_BACKUP,
|
||||
CONF_BATTERY_CHARGING,
|
||||
CONF_DAY,
|
||||
CONF_ON_ALARM,
|
||||
CONF_ON_TIMER,
|
||||
@@ -19,7 +15,6 @@ from esphome.components.rx8111.time import (
|
||||
CONF_TIMER_COUNT,
|
||||
CONF_TIMER_ENABLED,
|
||||
CONF_WEEKDAY,
|
||||
FOUT_FREQUENCIES,
|
||||
_validate_alarm_config,
|
||||
_validate_alarm_options,
|
||||
_validate_set_alarm,
|
||||
@@ -79,104 +74,3 @@ def test_on_alarm_requires_enabled_alarm() -> None:
|
||||
with pytest.raises(cv.Invalid, match="on_alarm"):
|
||||
_validate_alarm_config({CONF_ON_ALARM: [{}]})
|
||||
assert _validate_alarm_config({CONF_ON_ALARM: [{}], CONF_ALARM_ENABLED: True})
|
||||
|
||||
|
||||
def test_charging_requires_explicit_backup() -> None:
|
||||
for config in (
|
||||
{CONF_BATTERY_CHARGING: True},
|
||||
{CONF_BATTERY_CHARGING: False},
|
||||
{CONF_BATTERY_BACKUP: False, CONF_BATTERY_CHARGING: True},
|
||||
):
|
||||
with pytest.raises(cv.Invalid, match="battery_backup"):
|
||||
_validate_alarm_config(config)
|
||||
assert _validate_alarm_config(
|
||||
{CONF_BATTERY_BACKUP: True, CONF_BATTERY_CHARGING: True}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backup", [False, True])
|
||||
def test_charging_disable_requires_and_accepts_explicit_backup(backup: bool) -> None:
|
||||
config = {CONF_BATTERY_BACKUP: backup, CONF_BATTERY_CHARGING: False}
|
||||
assert _validate_alarm_config(config) == config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["32768hz", "1024hz", "1hz", "off"])
|
||||
def test_fout_frequencies(value: str) -> None:
|
||||
assert (
|
||||
cv.enum(FOUT_FREQUENCIES, lower=True)(value).enum_value
|
||||
== FOUT_FREQUENCIES[value]
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_fout_frequency() -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
cv.enum(FOUT_FREQUENCIES, lower=True)("100hz")
|
||||
|
||||
|
||||
def test_event_configuration_requires_enablement() -> None:
|
||||
from esphome.components.rx8111.time import (
|
||||
CONF_EVENT_ENABLED,
|
||||
CONF_EVENT_LEVEL,
|
||||
CONF_EVIN_FILTER,
|
||||
CONF_ON_EVENT,
|
||||
)
|
||||
|
||||
for config in (
|
||||
{CONF_ON_EVENT: [{}]},
|
||||
{CONF_EVENT_LEVEL: 0},
|
||||
{CONF_EVIN_FILTER: 2},
|
||||
):
|
||||
with pytest.raises(cv.Invalid):
|
||||
_validate_alarm_config(config)
|
||||
assert _validate_alarm_config({CONF_EVENT_ENABLED: True, CONF_ON_EVENT: [{}]})
|
||||
assert _validate_alarm_config({CONF_EVENT_ENABLED: False})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("option", ["event_timestamp", "timestamp_record_mode"])
|
||||
def test_timestamp_options_are_not_in_time_platform(option: str) -> None:
|
||||
from esphome.components.rx8111.time import CONFIG_SCHEMA
|
||||
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({option: "latest"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("options", "settings"),
|
||||
[
|
||||
({"event_enabled": True}, 0),
|
||||
(
|
||||
{
|
||||
"event_enabled": True,
|
||||
"event_level": "high",
|
||||
"evin_filter": 2,
|
||||
"evin_pull": "pullup_500k",
|
||||
},
|
||||
0xC4,
|
||||
),
|
||||
({"event_enabled": False}, None),
|
||||
],
|
||||
)
|
||||
async def test_event_codegen(options: dict, settings: int | None) -> None:
|
||||
from esphome.components.rx8111 import time as rx8111
|
||||
|
||||
config = rx8111.CONFIG_SCHEMA(options)
|
||||
var = mock.MagicMock()
|
||||
with (
|
||||
mock.patch.object(
|
||||
rx8111.i2c, "new_i2c_component", mock.AsyncMock(return_value=var)
|
||||
),
|
||||
mock.patch.object(rx8111.time, "register_time", mock.AsyncMock()),
|
||||
mock.patch.object(rx8111.cg, "add"),
|
||||
mock.patch.object(rx8111.cg, "add_optional"),
|
||||
mock.patch.object(
|
||||
rx8111.automation, "build_callback_automations", mock.AsyncMock()
|
||||
),
|
||||
):
|
||||
await rx8111.to_code(config)
|
||||
if settings is None:
|
||||
var.set_event_config.assert_not_called()
|
||||
var.set_event_disabled.assert_called_once_with()
|
||||
else:
|
||||
var.set_event_config.assert_called_once_with(settings)
|
||||
var.set_event_disabled.assert_not_called()
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Tests for template climate config validation."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
@@ -17,24 +14,17 @@ from esphome.components.template.climate import (
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_HUMIDITY,
|
||||
_resolve_supports,
|
||||
_sensor_reports_in_the_entity_unit,
|
||||
_validate_initial_state,
|
||||
_validate_set_actions,
|
||||
)
|
||||
from esphome.config import Config, read_config
|
||||
from esphome.const import (
|
||||
CONF_HUMIDITY_SENSOR,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_SENSOR,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_TEMPERATURE_HIGH,
|
||||
CONF_TARGET_TEMPERATURE_LOW,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
UNIT_FAHRENHEIT,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
@@ -153,162 +143,3 @@ def test_initial_state_matching_two_point_accepted() -> None:
|
||||
},
|
||||
}
|
||||
assert _validate_initial_state(config) is config
|
||||
|
||||
|
||||
def test_initial_state_temperature_follows_unit_of_measurement() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT),
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: (21.0, "°C")},
|
||||
}
|
||||
_validate_initial_state(config)
|
||||
assert config[CONF_INITIAL_STATE][CONF_TARGET_TEMPERATURE] == pytest.approx(69.8)
|
||||
|
||||
|
||||
def _config_with_sensor(unit: str | None) -> Config:
|
||||
temp = ID("temp", is_declaration=True, type="sensor::Sensor")
|
||||
full = Config()
|
||||
full["sensor"] = [
|
||||
{CONF_ID: temp}
|
||||
if unit is None
|
||||
else {CONF_ID: temp, CONF_UNIT_OF_MEASUREMENT: unit}
|
||||
]
|
||||
full.declare_ids.append((temp, ["sensor", 0, CONF_ID]))
|
||||
return full
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sensor_unit", "accepted"),
|
||||
[
|
||||
("°C", False),
|
||||
("°F", True),
|
||||
("%", True),
|
||||
(None, True),
|
||||
],
|
||||
ids=["celsius", "fahrenheit", "not a temperature", "no unit"],
|
||||
)
|
||||
def test_a_temperature_sensor_must_report_in_the_climate_unit(
|
||||
sensor_unit: str | None, accepted: bool
|
||||
) -> None:
|
||||
"""The sensor state is copied as is, so a temperature sensor in another unit is rejected."""
|
||||
config: ConfigType = {
|
||||
CONF_SENSOR: ID("temp", is_declaration=False),
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT),
|
||||
}
|
||||
token = fv.full_config.set(_config_with_sensor(sensor_unit))
|
||||
try:
|
||||
if accepted:
|
||||
assert _sensor_reports_in_the_entity_unit(config) is config
|
||||
else:
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="reports in °C but the climate reports in °F"
|
||||
):
|
||||
_sensor_reports_in_the_entity_unit(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_an_unresolved_sensor_unit_is_warned_about(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A sensor whose unit cannot be followed (a copy cycle) is accepted with a warning."""
|
||||
full = Config()
|
||||
for index, (sensor_id, source) in enumerate((("a", "b"), ("b", "a"))):
|
||||
declared = ID(sensor_id, is_declaration=True, type="sensor::Sensor")
|
||||
full.setdefault("sensor", []).append(
|
||||
{CONF_ID: declared, "platform": "copy", "source_id": ID(source)}
|
||||
)
|
||||
full.declare_ids.append((declared, ["sensor", index, CONF_ID]))
|
||||
config: ConfigType = {
|
||||
CONF_SENSOR: ID("a", is_declaration=False),
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT),
|
||||
}
|
||||
token = fv.full_config.set(full)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _sensor_reports_in_the_entity_unit(config) is config
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
assert "Could not determine the unit of sensor 'a'; it must report in °F" in (
|
||||
caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_a_climate_without_a_sensor_has_no_unit_to_check() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_UNIT_OF_MEASUREMENT: cv.temperature_unit(UNIT_FAHRENHEIT)
|
||||
}
|
||||
assert _sensor_reports_in_the_entity_unit(config) is config
|
||||
|
||||
|
||||
_SENSORS = """
|
||||
sensor:
|
||||
- platform: template
|
||||
id: source_temp
|
||||
unit_of_measurement: "{source_unit}"
|
||||
- platform: copy
|
||||
id: copied_temp
|
||||
source_id: source_temp
|
||||
- platform: copy
|
||||
id: copy_of_copy
|
||||
source_id: copied_temp
|
||||
"""
|
||||
|
||||
_CLIMATE = """
|
||||
climate:
|
||||
- platform: template
|
||||
id: thermostat
|
||||
name: Thermostat
|
||||
unit_of_measurement: °F
|
||||
sensor: {sensor}
|
||||
supported_modes: [HEAT]
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"climate_first", [True, False], ids=["climate first", "sensor first"]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("source_unit", "sensor", "accepted"),
|
||||
[
|
||||
("°C", "copied_temp", False),
|
||||
("°F", "copied_temp", True),
|
||||
("°C", "copy_of_copy", False),
|
||||
("°F", "copy_of_copy", True),
|
||||
],
|
||||
ids=[
|
||||
"copy celsius",
|
||||
"copy fahrenheit",
|
||||
"copy of copy celsius",
|
||||
"copy of copy fahrenheit",
|
||||
],
|
||||
)
|
||||
def test_an_inherited_sensor_unit_is_checked_in_any_section_order(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
climate_first: bool,
|
||||
source_unit: str,
|
||||
sensor: str,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""A copy sensor takes its unit from its source during final validation, which runs per
|
||||
component in config order; the climate check must see that unit before or after it."""
|
||||
sections = [
|
||||
_SENSORS.format(source_unit=source_unit),
|
||||
_CLIMATE.format(sensor=sensor),
|
||||
]
|
||||
if climate_first:
|
||||
sections.reverse()
|
||||
config_path = tmp_path / "test.yaml"
|
||||
config_path.write_text(
|
||||
"esphome:\n name: test\n\nesp8266:\n board: esp01_1m\n" + "".join(sections),
|
||||
encoding="utf-8",
|
||||
)
|
||||
CORE.config_path = config_path
|
||||
result = read_config({})
|
||||
if accepted:
|
||||
assert result is not None
|
||||
else:
|
||||
assert result is None
|
||||
assert "reports in °C but the climate reports in °F" in capsys.readouterr().out
|
||||
@@ -1,16 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: test
|
||||
password: testtest
|
||||
post_connect_roaming: false
|
||||
ap:
|
||||
ssid: fallback
|
||||
ap_mode: ALWAYS_ON
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: test
|
||||
password: testtest
|
||||
ap:
|
||||
ssid: fallback
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: test
|
||||
password: testtest
|
||||
ap:
|
||||
ssid: fallback
|
||||
password: fallback123
|
||||
# Route the AP subnet out through the station; the bridge has no portal.
|
||||
ap_mode: WISP
|
||||
@@ -1,18 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: test
|
||||
password: testtest
|
||||
ap:
|
||||
ssid: fallback
|
||||
password: fallback123
|
||||
ap_mode: WISP
|
||||
|
||||
captive_portal:
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Tests for the access point modes that keep the AP up: ``ALWAYS_ON`` and ``WISP``."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.wifi import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
from esphome.const import KEY_FRAMEWORK_VERSION, Platform, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.network import sdkconfig_option
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
_PERSISTENT = "USE_WIFI_AP_PERSISTENT"
|
||||
_WISP = "USE_WIFI_AP_WISP"
|
||||
_WISP_PORTAL = "USE_WIFI_AP_WISP_PORTAL"
|
||||
|
||||
_STA_NETWORK: ConfigType = {"ssid": "test", "password": "testtest"}
|
||||
_ALWAYS_ON_AP: ConfigType = {"ssid": "fallback", "ap_mode": "ALWAYS_ON"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> Callable[..., None]:
|
||||
"""Run the wifi schema and its final validation on a target, as loading a config does."""
|
||||
|
||||
def _validate(
|
||||
wifi: ConfigType,
|
||||
platform_framework: PlatformFramework = PlatformFramework.ESP32_IDF,
|
||||
full_config: ConfigType | None = None,
|
||||
) -> None:
|
||||
platform, _ = platform_framework.value
|
||||
set_core_config(
|
||||
platform_framework,
|
||||
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
|
||||
# The esp32 split defaults are keyed by variant
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}
|
||||
if platform is Platform.ESP32
|
||||
else {},
|
||||
full_config=full_config,
|
||||
)
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({"use_address": "192.168.1.1", **wifi}))
|
||||
|
||||
return _validate
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "present", "absent", "napt"),
|
||||
[
|
||||
("ap_default.yaml", set(), {_PERSISTENT, _WISP, _WISP_PORTAL}, None),
|
||||
("always_on.yaml", {_PERSISTENT}, {_WISP, _WISP_PORTAL}, None),
|
||||
("wisp.yaml", {_PERSISTENT, _WISP}, {_WISP_PORTAL}, True),
|
||||
("wisp_portal.yaml", {_PERSISTENT, _WISP, _WISP_PORTAL}, set(), True),
|
||||
],
|
||||
)
|
||||
def test_ap_mode_defines(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
present: set[str],
|
||||
absent: set[str],
|
||||
napt: bool | None,
|
||||
) -> None:
|
||||
"""Only WISP routes; the AP stays up in both modes, and a WISP portal gets its own define."""
|
||||
generate_main(component_config_path(config_file))
|
||||
defines = {define.name for define in CORE.defines}
|
||||
|
||||
assert "USE_WIFI_AP" in defines
|
||||
assert present <= defines
|
||||
assert not absent & defines
|
||||
assert sdkconfig_option("CONFIG_LWIP_IP_FORWARD") is napt
|
||||
assert sdkconfig_option("CONFIG_LWIP_IPV4_NAPT") is napt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_framework", "wifi", "match"),
|
||||
[
|
||||
(
|
||||
PlatformFramework.ESP8266_ARDUINO,
|
||||
{"networks": [_STA_NETWORK], "ap": _ALWAYS_ON_AP},
|
||||
"only available on",
|
||||
),
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
{"post_connect_roaming": False, "ap": _ALWAYS_ON_AP},
|
||||
"ALWAYS_ON needs a station network",
|
||||
),
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
{"networks": [_STA_NETWORK], "ap": _ALWAYS_ON_AP},
|
||||
"incompatible with post_connect_roaming",
|
||||
),
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
{
|
||||
"networks": [_STA_NETWORK],
|
||||
"post_connect_roaming": False,
|
||||
"ap": {**_ALWAYS_ON_AP, "ap_timeout": "0s"},
|
||||
},
|
||||
"ap_timeout is how long ap_mode: ALWAYS_ON waits",
|
||||
),
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
{
|
||||
"networks": [_STA_NETWORK],
|
||||
"post_connect_roaming": False,
|
||||
"ap": {**_ALWAYS_ON_AP, "dns_servers": ["1.1.1.1"]},
|
||||
},
|
||||
"dns_servers requires ap_mode: WISP",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_always_on_invalid(
|
||||
validate: Callable[..., None],
|
||||
platform_framework: PlatformFramework,
|
||||
wifi: ConfigType,
|
||||
match: str,
|
||||
) -> None:
|
||||
with pytest.raises(cv.Invalid, match=match):
|
||||
validate(wifi, platform_framework)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_framework", "wifi", "full_config"),
|
||||
[
|
||||
(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
{
|
||||
"networks": [_STA_NETWORK],
|
||||
"post_connect_roaming": False,
|
||||
"ap": {**_ALWAYS_ON_AP, "ap_timeout": "30s"},
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
{
|
||||
"networks": [_STA_NETWORK],
|
||||
"post_connect_roaming": False,
|
||||
"ap": _ALWAYS_ON_AP,
|
||||
},
|
||||
None,
|
||||
),
|
||||
# Ethernet or a modem is the upstream; with no station nothing can roam
|
||||
(PlatformFramework.ESP32_IDF, {"ap": _ALWAYS_ON_AP}, {"ethernet": {}}),
|
||||
(PlatformFramework.ESP32_IDF, {"ap": _ALWAYS_ON_AP}, {"modem": {}}),
|
||||
],
|
||||
)
|
||||
def test_always_on_valid(
|
||||
validate: Callable[..., None],
|
||||
platform_framework: PlatformFramework,
|
||||
wifi: ConfigType,
|
||||
full_config: ConfigType | None,
|
||||
) -> None:
|
||||
validate(wifi, platform_framework, full_config)
|
||||
@@ -1,12 +0,0 @@
|
||||
zephyr_ble_server:
|
||||
# Turns on pairing, so the trigger characteristic needs an authenticated link
|
||||
on_numeric_comparison_request:
|
||||
then:
|
||||
- ble_server.numeric_comparison_reply:
|
||||
accept: true
|
||||
|
||||
ota:
|
||||
- platform: adafruit_ble
|
||||
on_begin:
|
||||
then:
|
||||
- logger.log: "OTA start"
|
||||
@@ -1,83 +0,0 @@
|
||||
esp32_ble_tracker:
|
||||
|
||||
ble_client:
|
||||
- mac_address: 01:02:03:04:05:06
|
||||
id: test_midi_client
|
||||
|
||||
ble_midi:
|
||||
id: test_midi
|
||||
ble_client_id: test_midi_client
|
||||
max_sysex_size: 64
|
||||
pair: false
|
||||
on_connect:
|
||||
- logger.log: "MIDI device connected"
|
||||
- ble_midi.send_program_change:
|
||||
id: test_midi
|
||||
program: 0
|
||||
on_disconnect:
|
||||
- logger.log: "MIDI device disconnected"
|
||||
on_note_on:
|
||||
- logger.log:
|
||||
format: "Note on %u velocity %u channel %u"
|
||||
args: [note, velocity, channel]
|
||||
on_note_off:
|
||||
- logger.log:
|
||||
format: "Note off %u channel %u"
|
||||
args: [note, channel]
|
||||
on_control_change:
|
||||
- logger.log:
|
||||
format: "CC %u = %u on channel %u"
|
||||
args: [control_number, value, channel]
|
||||
on_program_change:
|
||||
- logger.log:
|
||||
format: "Program %u on channel %u"
|
||||
args: [program, channel]
|
||||
on_pitch_bend:
|
||||
- logger.log:
|
||||
format: "Pitch bend %d on channel %u"
|
||||
args: [value, channel]
|
||||
on_sysex:
|
||||
- logger.log:
|
||||
format: "SysEx of %u bytes"
|
||||
args: ["data.size()"]
|
||||
on_message:
|
||||
- logger.log:
|
||||
format: "Raw message of %u bytes"
|
||||
args: ["data.size()"]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "MIDI send tests"
|
||||
on_press:
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: 60
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: !lambda "return 60;"
|
||||
velocity: !lambda "return 100;"
|
||||
channel: !lambda "return 1;"
|
||||
- ble_midi.send_note_off:
|
||||
id: test_midi
|
||||
note: 60
|
||||
velocity: 0
|
||||
channel: 1
|
||||
- ble_midi.send_control_change:
|
||||
id: test_midi
|
||||
control_number: 7
|
||||
value: 64
|
||||
- ble_midi.send_pitch_bend:
|
||||
id: test_midi
|
||||
value: -8192
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: [0x7E, 0x7F, 0x06, 0x01]
|
||||
- ble_midi.send_raw:
|
||||
id: test_midi
|
||||
data: !lambda "return std::vector<uint8_t>{0x90, 0x3C, 0x40};"
|
||||
- if:
|
||||
condition:
|
||||
ble_midi.connected:
|
||||
id: test_midi
|
||||
then:
|
||||
- logger.log: "Still connected"
|
||||
@@ -1,283 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/ble_midi/midi_parser.h"
|
||||
|
||||
namespace esphome::ble_midi::testing {
|
||||
|
||||
// A BLE-MIDI packet is a header byte, then timestamp/MIDI byte pairs. Both
|
||||
// header and timestamp have bit 7 set; their remaining bits carry a timestamp
|
||||
// that this component ignores.
|
||||
static constexpr uint8_t HDR = 0x80;
|
||||
static constexpr uint8_t TS = 0x80;
|
||||
|
||||
struct Decoded {
|
||||
MessageType type;
|
||||
std::vector<uint8_t> bytes;
|
||||
};
|
||||
|
||||
class MidiParserTest : public ::testing::Test {
|
||||
protected:
|
||||
static void collect(void *ctx, const MidiMessage &message) {
|
||||
static_cast<MidiParserTest *>(ctx)->messages_.push_back(
|
||||
{message.type, std::vector<uint8_t>(message.data, message.data + message.length)});
|
||||
}
|
||||
void feed_(const std::vector<uint8_t> &packet) { this->parser_.feed(packet.data(), packet.size(), collect, this); }
|
||||
|
||||
uint8_t sysex_buffer_[64]{};
|
||||
MidiParser parser_{sysex_buffer_, sizeof(sysex_buffer_)};
|
||||
std::vector<Decoded> messages_;
|
||||
};
|
||||
|
||||
TEST_F(MidiParserTest, NoteOn) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::NOTE_ON);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0x90, 0x3C, 0x40}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, ChannelIsTakenFromTheStatusByte) {
|
||||
this->feed_({HDR, TS, 0x95, 0x3C, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
MidiMessage message{this->messages_[0].bytes.data(), 3, this->messages_[0].type};
|
||||
EXPECT_EQ(message.channel(), 5);
|
||||
EXPECT_EQ(message.data1(), 60);
|
||||
EXPECT_EQ(message.data2(), 64);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, RunningStatusReusesThePreviousStatusByte) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40, 0x3E, 0x41});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::NOTE_ON);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0x90, 0x3E, 0x41}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, RunningStatusSurvivesAcrossPackets) {
|
||||
this->feed_({HDR, TS, 0xB0, 0x07, 0x10});
|
||||
this->feed_({HDR, 0x07, 0x20});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::CONTROL_CHANGE);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0xB0, 0x07, 0x20}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, ResetClearsRunningStatus) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40});
|
||||
this->parser_.reset();
|
||||
this->messages_.clear();
|
||||
this->feed_({HDR, TS, 0x3E, 0x41});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::UNKNOWN);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::UNKNOWN);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, NoteOnWithZeroVelocityIsANoteOff) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x00});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::NOTE_OFF);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, ControlChange) {
|
||||
this->feed_({HDR, TS, 0xB2, 0x07, 0x64});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
MidiMessage message{this->messages_[0].bytes.data(), 3, this->messages_[0].type};
|
||||
EXPECT_EQ(message.type, MessageType::CONTROL_CHANGE);
|
||||
EXPECT_EQ(message.channel(), 2);
|
||||
EXPECT_EQ(message.data1(), 7);
|
||||
EXPECT_EQ(message.data2(), 100);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, ProgramChangeHasASingleDataByte) {
|
||||
this->feed_({HDR, TS, 0xC0, 0x05, TS, 0xC0, 0x06});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::PROGRAM_CHANGE);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xC0, 0x05}));
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0xC0, 0x06}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, PitchBendIsCenteredOnZero) {
|
||||
this->feed_({HDR, TS, 0xE0, 0x00, 0x40, TS, 0xE0, 0x00, 0x00, TS, 0xE0, 0x7F, 0x7F});
|
||||
ASSERT_EQ(this->messages_.size(), 3u);
|
||||
auto bend = [this](size_t index) {
|
||||
MidiMessage message{this->messages_[index].bytes.data(), 3, this->messages_[index].type};
|
||||
return message.pitch_bend();
|
||||
};
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::PITCH_BEND);
|
||||
EXPECT_EQ(bend(0), 0);
|
||||
EXPECT_EQ(bend(1), -8192);
|
||||
EXPECT_EQ(bend(2), 8191);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, RealTimeMessageIsASingleByte) {
|
||||
this->feed_({HDR, TS, 0xF8});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSTEM_REAL_TIME);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF8}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, RealTimeMessageInsideAChannelMessageLeavesItIntact) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, TS, 0xF8, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSTEM_REAL_TIME);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::NOTE_ON);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0x90, 0x3C, 0x40}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SysexCutsAPartialChannelMessageShort) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, TS, 0xF0, 0x01, TS, 0xF7, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 3u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::INCOMPLETE);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0x90, 0x3C}));
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::SYSEX);
|
||||
// No status is in effect after a SysEx, so the stray data byte does not finish the Note On
|
||||
EXPECT_EQ(this->messages_[2].type, MessageType::UNKNOWN);
|
||||
EXPECT_EQ(this->messages_[2].bytes, std::vector<uint8_t>({0x40}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SystemCommonMessageWithTwoDataBytes) {
|
||||
this->feed_({HDR, TS, 0xF2, 0x01, 0x02});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSTEM_COMMON);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF2, 0x01, 0x02}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SystemCommonClearsRunningStatus) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40, TS, 0xF6});
|
||||
this->messages_.clear();
|
||||
this->feed_({HDR, 0x3E, 0x41});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::UNKNOWN);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SysexInASinglePacket) {
|
||||
this->feed_({HDR, TS, 0xF0, 0x7E, 0x7F, 0x06, 0x01, TS, 0xF7});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSEX);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SysexReassembledAcrossPackets) {
|
||||
this->feed_({HDR, TS, 0xF0, 0x7E, 0x7F});
|
||||
EXPECT_TRUE(this->messages_.empty());
|
||||
this->feed_({HDR, 0x06, 0x01, TS, 0xF7});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, RealTimeMessageInterruptsSysex) {
|
||||
this->feed_({HDR, TS, 0xF0, 0x7E, TS, 0xF8, 0x7F, TS, 0xF7});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSTEM_REAL_TIME);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::SYSEX);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0xF0, 0x7E, 0x7F, 0xF7}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SysexLongerThanTheBufferIsReportedIncomplete) {
|
||||
uint8_t small_buffer[8]{};
|
||||
MidiParser small_parser{small_buffer, sizeof(small_buffer)};
|
||||
std::vector<uint8_t> packet{HDR, TS, 0xF0};
|
||||
for (uint8_t i = 0; i < 64; i++)
|
||||
packet.push_back(i & 0x7F);
|
||||
packet.push_back(TS);
|
||||
packet.push_back(0xF7);
|
||||
|
||||
small_parser.feed(packet.data(), packet.size(), collect, this);
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSEX_INCOMPLETE);
|
||||
EXPECT_EQ(this->messages_[0].bytes.size(), 8u);
|
||||
|
||||
// The next SysEx that fits is reported whole
|
||||
const std::vector<uint8_t> start{HDR, TS, 0xF0, 0x00, 0x01};
|
||||
const std::vector<uint8_t> end{HDR, TS, 0xF7};
|
||||
small_parser.feed(start.data(), start.size(), collect, this);
|
||||
small_parser.feed(end.data(), end.size(), collect, this);
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::SYSEX);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0xF0, 0x00, 0x01, 0xF7}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, StatusByteCuttingASysexShortDropsItAndIsDecoded) {
|
||||
this->feed_({HDR, TS, 0xF0, 0x7E, TS, 0x90, 0x3C, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSEX_INCOMPLETE);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF0, 0x7E}));
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::NOTE_ON);
|
||||
EXPECT_EQ(this->messages_[1].bytes, std::vector<uint8_t>({0x90, 0x3C, 0x40}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, SysexCarriedOverEndsWhenTheNextPacketStartsAMessage) {
|
||||
this->feed_({HDR, TS, 0xF0, 0x7E});
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40, 0x3E, 0x41});
|
||||
ASSERT_EQ(this->messages_.size(), 3u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::SYSEX_INCOMPLETE);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0xF0, 0x7E}));
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::NOTE_ON);
|
||||
// Running status works again after the SysEx was closed
|
||||
EXPECT_EQ(this->messages_[2].bytes, std::vector<uint8_t>({0x90, 0x3E, 0x41}));
|
||||
}
|
||||
|
||||
TEST(FrameSysex, AddsTheWrappersAndTheTimestampBeforeTheEnd) {
|
||||
uint8_t out[16];
|
||||
const uint8_t payload[] = {0x7E, 0x7F, 0x06, 0x01};
|
||||
ASSERT_EQ(frame_sysex(payload, sizeof(payload), out, sizeof(out)), 7u);
|
||||
EXPECT_EQ(std::vector<uint8_t>(out, out + 7), std::vector<uint8_t>({0xF0, 0x7E, 0x7F, 0x06, 0x01, TS, 0xF7}));
|
||||
}
|
||||
|
||||
TEST(FrameSysex, KeepsWrappersThatArePresent) {
|
||||
uint8_t out[16];
|
||||
const uint8_t payload[] = {0xF0, 0x7E, 0xF7};
|
||||
ASSERT_EQ(frame_sysex(payload, sizeof(payload), out, sizeof(out)), 4u);
|
||||
EXPECT_EQ(std::vector<uint8_t>(out, out + 4), std::vector<uint8_t>({0xF0, 0x7E, TS, 0xF7}));
|
||||
}
|
||||
|
||||
TEST(FrameSysex, RejectsEmptyAndOversizedPayloads) {
|
||||
uint8_t out[4];
|
||||
const uint8_t payload[] = {0x7E, 0x7F};
|
||||
EXPECT_EQ(frame_sysex(payload, 0, out, sizeof(out)), 0u);
|
||||
EXPECT_EQ(frame_sysex(payload, sizeof(payload), out, sizeof(out)), 0u);
|
||||
}
|
||||
|
||||
TEST(FrameSysex, RejectsABodyByteWithBit7Set) {
|
||||
uint8_t out[16];
|
||||
const uint8_t payload[] = {0x7E, 0x80, 0x01};
|
||||
EXPECT_EQ(frame_sysex(payload, sizeof(payload), out, sizeof(out)), 0u);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, DataByteWithoutStatusContextIsReportedAsUnknown) {
|
||||
this->feed_({HDR, TS, 0x3C});
|
||||
ASSERT_EQ(this->messages_.size(), 1u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::UNKNOWN);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0x3C}));
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, PacketWithoutAHeaderByteIsRejected) {
|
||||
this->feed_({0x3C, 0x40});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::UNKNOWN);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::UNKNOWN);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, EmptyAndSingleByteInputAreHandled) {
|
||||
this->feed_({});
|
||||
this->feed_({HDR});
|
||||
EXPECT_EQ(this->messages_.size(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, IncompleteMessageIsReportedWhenANewStatusByteArrives) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, TS, 0xB0, 0x07, 0x10});
|
||||
ASSERT_EQ(this->messages_.size(), 2u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::INCOMPLETE);
|
||||
EXPECT_EQ(this->messages_[0].bytes, std::vector<uint8_t>({0x90, 0x3C}));
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::CONTROL_CHANGE);
|
||||
}
|
||||
|
||||
TEST_F(MidiParserTest, MultipleMessagesInOnePacket) {
|
||||
this->feed_({HDR, TS, 0x90, 0x3C, 0x40, TS, 0x80, 0x3C, 0x00, TS, 0xB0, 0x07, 0x7F});
|
||||
ASSERT_EQ(this->messages_.size(), 3u);
|
||||
EXPECT_EQ(this->messages_[0].type, MessageType::NOTE_ON);
|
||||
EXPECT_EQ(this->messages_[1].type, MessageType::NOTE_OFF);
|
||||
EXPECT_EQ(this->messages_[2].type, MessageType::CONTROL_CHANGE);
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_midi::testing
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
|
||||
ble_midi: !include common.yaml
|
||||
@@ -56,16 +56,3 @@ button:
|
||||
name: Start Charging
|
||||
stop_charging:
|
||||
name: Stop Charging
|
||||
- platform: template
|
||||
name: Set Max Current 16
|
||||
on_press:
|
||||
- number.set:
|
||||
id: wallbox_max_current
|
||||
value: 16
|
||||
|
||||
number:
|
||||
- platform: delta_wallbox
|
||||
delta_wallbox_id: my_charger
|
||||
max_current:
|
||||
id: wallbox_max_current
|
||||
name: Max Current Setting
|
||||
@@ -12,8 +12,7 @@ mqtt:
|
||||
username: debug
|
||||
password: debug
|
||||
enable_on_boot: false
|
||||
clean_session: false
|
||||
persist_subscriptions: true
|
||||
clean_session: True
|
||||
client_id: someclient
|
||||
use_abbreviations: false
|
||||
discovery: true
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <initializer_list>
|
||||
#include <utility>
|
||||
|
||||
#include "esphome/components/mqtt/mqtt_subscription_fingerprint.h"
|
||||
|
||||
namespace esphome::mqtt::testing {
|
||||
|
||||
static constexpr uint32_t SEED = 0x12345678;
|
||||
|
||||
static uint32_t fingerprint(std::initializer_list<std::pair<const char *, uint8_t>> subscriptions) {
|
||||
uint32_t value = SEED;
|
||||
for (const auto &[topic, qos] : subscriptions)
|
||||
value = add_subscription_fingerprint(value, topic, qos);
|
||||
return value;
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, OrderDoesNotMatter) {
|
||||
EXPECT_EQ(fingerprint({{"a/set", 0}, {"b/set", 1}, {"c/set", 0}}),
|
||||
fingerprint({{"c/set", 0}, {"a/set", 0}, {"b/set", 1}}));
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, TopicQosAndCountChangeIt) {
|
||||
const uint32_t base = fingerprint({{"a/set", 0}, {"b/set", 0}});
|
||||
EXPECT_NE(base, fingerprint({{"a/set", 0}, {"b/cmd", 0}}));
|
||||
EXPECT_NE(base, fingerprint({{"a/set", 0}, {"b/set", 1}}));
|
||||
EXPECT_NE(base, fingerprint({{"a/set", 0}}));
|
||||
// Duplicates do not cancel each other out
|
||||
EXPECT_NE(base, fingerprint({{"a/set", 0}, {"b/set", 0}, {"b/set", 0}, {"b/set", 0}}));
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, ChangedSetSubscribesAgain) {
|
||||
const uint32_t trusted = fingerprint({{"a/set", 0}, {"b/set", 1}});
|
||||
EXPECT_TRUE(session_has_subscriptions(true, trusted, fingerprint({{"b/set", 1}, {"a/set", 0}})));
|
||||
EXPECT_FALSE(session_has_subscriptions(true, trusted, fingerprint({{"a/set", 0}, {"c/set", 1}})));
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, NewSessionSubscribesAgain) {
|
||||
const uint32_t trusted = fingerprint({{"a/set", 0}});
|
||||
EXPECT_FALSE(session_has_subscriptions(false, trusted, trusted));
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, UntrustedNeverMatches) {
|
||||
EXPECT_FALSE(session_has_subscriptions(true, UNTRUSTED_SESSION_FINGERPRINT, UNTRUSTED_SESSION_FINGERPRINT));
|
||||
}
|
||||
|
||||
TEST(SubscriptionFingerprint, RefusedSubackIsNotGranted) {
|
||||
EXPECT_TRUE(subscribe_granted(0));
|
||||
EXPECT_TRUE(subscribe_granted(2));
|
||||
EXPECT_FALSE(subscribe_granted(0x80));
|
||||
// MQTT 5 reason codes
|
||||
EXPECT_FALSE(subscribe_granted(0x87));
|
||||
}
|
||||
|
||||
TEST(SubackCounter, PendingUntilEverySubackIsIn) {
|
||||
SubackCounter subacks;
|
||||
EXPECT_EQ(subacks.tally(2), SubackTally::SUBACK_TALLY_PENDING);
|
||||
subacks.add(0);
|
||||
EXPECT_EQ(subacks.tally(2), SubackTally::SUBACK_TALLY_PENDING);
|
||||
subacks.add(1);
|
||||
EXPECT_EQ(subacks.tally(2), SubackTally::SUBACK_TALLY_ALL_GRANTED);
|
||||
}
|
||||
|
||||
TEST(SubackCounter, NothingSentIsSettled) {
|
||||
SubackCounter subacks;
|
||||
EXPECT_EQ(subacks.tally(0), SubackTally::SUBACK_TALLY_ALL_GRANTED);
|
||||
}
|
||||
|
||||
TEST(SubackCounter, RefusalSettlesWithoutTrust) {
|
||||
SubackCounter subacks;
|
||||
subacks.add(0x80);
|
||||
EXPECT_EQ(subacks.tally(2), SubackTally::SUBACK_TALLY_PENDING);
|
||||
subacks.add(0);
|
||||
EXPECT_EQ(subacks.tally(2), SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED);
|
||||
}
|
||||
|
||||
TEST(SubackCounter, ExtraSubackIsNotTrusted) {
|
||||
// A SUBACK for a SUBSCRIBE resent from an earlier connection
|
||||
SubackCounter subacks;
|
||||
subacks.add(0);
|
||||
subacks.add(0);
|
||||
EXPECT_EQ(subacks.tally(1), SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED);
|
||||
}
|
||||
|
||||
TEST(SubackCounter, ResetStartsANewConnection) {
|
||||
SubackCounter subacks;
|
||||
subacks.add(0x80);
|
||||
subacks.reset();
|
||||
subacks.add(0);
|
||||
EXPECT_EQ(subacks.tally(1), SubackTally::SUBACK_TALLY_ALL_GRANTED);
|
||||
}
|
||||
|
||||
} // namespace esphome::mqtt::testing
|
||||
@@ -23,7 +23,6 @@ esphome:
|
||||
duration: !lambda return uint32_t{60};
|
||||
- rx8111.clear_alarm_flag: rtc
|
||||
- rx8111.clear_timer_flag: rtc
|
||||
- rx8111.clear_event_flag: rtc
|
||||
- rx8111.stop_timer: rtc
|
||||
- rx8111.start_timer: rtc
|
||||
- rx8111.unset_alarm: rtc
|
||||
@@ -39,16 +38,6 @@ time:
|
||||
timer_count: 2
|
||||
timer_clock: 1hz
|
||||
event_poll_interval: 500ms
|
||||
battery_backup: true
|
||||
battery_charging: true
|
||||
fout_frequency: 1024hz
|
||||
event_enabled: true
|
||||
event_level: low
|
||||
evin_filter: 2
|
||||
evin_pull: pullup_500k
|
||||
on_event:
|
||||
then:
|
||||
- rx8111.clear_event_flag: rtc
|
||||
on_alarm:
|
||||
then:
|
||||
- rx8111.clear_alarm_flag: rtc
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <array>
|
||||
#include <ctime>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/rx8111/rx8111.h"
|
||||
@@ -20,21 +19,14 @@ static constexpr uint8_t CONTROL = 0x1F;
|
||||
static constexpr uint8_t EXTENSION = 0x1D;
|
||||
static constexpr uint8_t SEC_ALARM = 0x2C;
|
||||
static constexpr uint8_t MIN_ALARM = 0x17;
|
||||
static constexpr uint8_t POWER_SWITCH = 0x32;
|
||||
static constexpr uint8_t TIMER_CONTROL = 0x2D;
|
||||
static constexpr uint8_t EVIN = 0x2B;
|
||||
static constexpr uint8_t TIMESTAMP_CONTROL = 0x34;
|
||||
|
||||
class RegisterBus final : public i2c::I2CBus {
|
||||
public:
|
||||
std::array<uint8_t, 256> regs{};
|
||||
std::vector<uint8_t> writes;
|
||||
std::vector<std::pair<uint8_t, size_t>> reads;
|
||||
int fail_read{-1};
|
||||
int fail_read_once{-1};
|
||||
int fail_write{-1};
|
||||
int fail_control_write_number{-1};
|
||||
unsigned control_writes{0};
|
||||
unsigned flag_reads{0};
|
||||
bool latch_event_before_flag_write{false};
|
||||
|
||||
@@ -46,20 +38,17 @@ class RegisterBus final : public i2c::I2CBus {
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
const uint8_t reg = out[0];
|
||||
if (in_size > 0) {
|
||||
this->reads.emplace_back(reg, in_size);
|
||||
if (reg == FLAG)
|
||||
this->flag_reads++;
|
||||
if (this->fail_read >= reg && this->fail_read < reg + in_size)
|
||||
if (reg == this->fail_read)
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
if (this->fail_read_once >= reg && this->fail_read_once < reg + in_size) {
|
||||
if (reg == this->fail_read_once) {
|
||||
this->fail_read_once = -1;
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
}
|
||||
for (size_t i = 0; i < in_size; i++)
|
||||
in[i] = this->regs[reg + i];
|
||||
} else {
|
||||
if (reg == CONTROL && ++this->control_writes == this->fail_control_write_number)
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
if (reg == this->fail_write)
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
this->writes.push_back(reg);
|
||||
@@ -91,9 +80,6 @@ class RX8111Test : public ::testing::Test {
|
||||
App.scheduler.cancel_interval(&this->rtc_, uint32_t{0});
|
||||
App.scheduler.cancel_timeout(&this->rtc_, uint32_t{1});
|
||||
App.scheduler.cancel_timeout(&this->rtc_, uint32_t{2});
|
||||
App.scheduler.cancel_timeout(&this->rtc_, uint32_t{3});
|
||||
App.scheduler.cancel_timeout(&this->rtc_, uint32_t{4});
|
||||
App.scheduler.cancel_timeout(&this->rtc_, uint32_t{5});
|
||||
App.scheduler.call(millis()); // Reap cancelled timers while the fixture is still alive.
|
||||
}
|
||||
|
||||
@@ -105,501 +91,12 @@ class RX8111Test : public ::testing::Test {
|
||||
// TEST_F bodies are subclasses, so the friendship stops here
|
||||
bool polling_started_() const { return this->rtc_.event_polling_started_; }
|
||||
bool timer_enabled_() const { return this->rtc_.timer_enabled_; }
|
||||
void configure_battery_() { this->rtc_.configure_boot_battery_(); }
|
||||
bool power_warning_() const { return (this->rtc_.warnings_ & RX8111Component::WARN_BATTERY) != 0; }
|
||||
bool fout_warning_() const { return (this->rtc_.warnings_ & RX8111Component::WARN_FOUT) != 0; }
|
||||
bool timer_warning_() const { return (this->rtc_.warnings_ & RX8111Component::WARN_TIMER) != 0; }
|
||||
bool event_warning_() const { return (this->rtc_.warnings_ & RX8111Component::WARN_EVENT) != 0; }
|
||||
bool event_clear_warning_() const { return (this->rtc_.warnings_ & RX8111Component::WARN_EVENT_FLAG_CLEAR) != 0; }
|
||||
void poll_() { this->rtc_.check_event_flags_(); }
|
||||
|
||||
RegisterBus bus_;
|
||||
RX8111Component rtc_;
|
||||
unsigned syncs_{0};
|
||||
};
|
||||
|
||||
TEST_F(RX8111Test, OmittedPowerAndFoutOptionsDoNotTouchBatteryBackedRegisters) {
|
||||
this->bus_.regs[POWER_SWITCH] = 0xC5;
|
||||
this->bus_.regs[EXTENSION] = 0xA7;
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0xC5);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xA7);
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventSettingsPreserveUnrelatedBitsAndLatchedFlag) {
|
||||
this->bus_.regs[TIMESTAMP_CONTROL] = 0x85; // EISEL plus TSRAM and a reserved bit.
|
||||
this->bus_.regs[EVIN] = 1;
|
||||
this->bus_.regs[EXTENSION] = 0x90;
|
||||
this->bus_.regs[CONTROL] = 0x90;
|
||||
this->bus_.regs[FLAG] = 0x04;
|
||||
this->rtc_.set_event_config(0xC4);
|
||||
int callbacks = 0;
|
||||
this->rtc_.add_on_event_callback([&]() { callbacks++; });
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[EVIN], 0xC5);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0x94); // ETS enables EVIN detection; preserve other bits.
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL], 0x94);
|
||||
EXPECT_EQ(this->bus_.regs[TIMESTAMP_CONTROL], 0x81);
|
||||
this->poll_();
|
||||
this->poll_();
|
||||
EXPECT_EQ(callbacks, 1);
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x04);
|
||||
EXPECT_TRUE(this->rtc_.clear_event_flag());
|
||||
this->bus_.regs[FLAG] = 0x04;
|
||||
this->poll_();
|
||||
EXPECT_EQ(callbacks, 2);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventWithoutCallbackConfiguresHardwareWithoutPolling) {
|
||||
this->rtc_.set_event_config(0xC4);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0x04);
|
||||
EXPECT_FALSE(this->polling_started_());
|
||||
const auto reads = this->bus_.flag_reads;
|
||||
this->rtc_.update();
|
||||
// One calendar FLAG read, no event FLAG read.
|
||||
EXPECT_EQ(this->bus_.flag_reads, reads + 1);
|
||||
EXPECT_TRUE(this->rtc_.clear_event_flag());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventWithCallbackStartsPolling) {
|
||||
this->rtc_.set_event_config(0);
|
||||
this->rtc_.add_on_event_callback([]() {});
|
||||
this->rtc_.setup();
|
||||
EXPECT_TRUE(this->polling_started_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventConfigurationRetriesWithoutDroppingRetainedFlag) {
|
||||
this->bus_.regs[EVIN] = 0x03; // Preserve EVIN's reserved and retained capture bits.
|
||||
this->bus_.regs[FLAG] = 0x04;
|
||||
this->bus_.fail_write = EVIN;
|
||||
this->rtc_.set_event_config(0xC4);
|
||||
int callbacks = 0;
|
||||
this->rtc_.add_on_event_callback([&]() { callbacks++; });
|
||||
this->rtc_.setup();
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[EVIN], 0x03);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x04, 0);
|
||||
this->poll_();
|
||||
EXPECT_EQ(callbacks, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_FALSE(this->event_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[EVIN], 0xC7);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x04, 0x04);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0x04);
|
||||
this->poll_();
|
||||
EXPECT_EQ(callbacks, 1);
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x04);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventExtensionFailureRetriesBeforeEnablingInterrupt) {
|
||||
this->bus_.regs[EXTENSION] = 0xA0;
|
||||
this->bus_.fail_write = EXTENSION;
|
||||
this->rtc_.set_event_config(0xD0);
|
||||
this->rtc_.setup();
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[EVIN], 0xD0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xA0);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_FALSE(this->event_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xA4);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0x04);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ExhaustedEventStartupRetriesDoNotKeepPollingFlags) {
|
||||
this->rtc_.set_event_config(0);
|
||||
unsigned callbacks = 0;
|
||||
this->rtc_.add_on_event_callback([&callbacks]() { callbacks++; });
|
||||
this->bus_.fail_write = EVIN;
|
||||
this->rtc_.setup();
|
||||
EXPECT_FALSE(this->polling_started_());
|
||||
this->advance_retry_();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
this->bus_.fail_write = -1;
|
||||
this->bus_.regs[FLAG] = 0x04;
|
||||
const auto reads = this->bus_.flag_reads;
|
||||
for (unsigned i = 0; i < 5; i++) {
|
||||
this->advance_retry_();
|
||||
this->poll_();
|
||||
}
|
||||
EXPECT_EQ(this->bus_.flag_reads, reads);
|
||||
EXPECT_EQ(callbacks, 0);
|
||||
EXPECT_FALSE(this->polling_started_());
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FailedEventStartupDoesNotStopTimerPolling) {
|
||||
this->rtc_.set_event_config(0);
|
||||
this->rtc_.add_on_event_callback([]() { FAIL() << "EVIN configuration never succeeded"; });
|
||||
this->rtc_.set_timer_count(2);
|
||||
this->rtc_.set_timer_configured(true);
|
||||
this->rtc_.set_timer_enabled(true);
|
||||
unsigned callbacks = 0;
|
||||
this->rtc_.add_on_timer_callback([&callbacks]() { callbacks++; });
|
||||
this->bus_.fail_write = EVIN;
|
||||
this->rtc_.setup();
|
||||
this->advance_retry_();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->polling_started_());
|
||||
this->bus_.regs[FLAG] = 0x14;
|
||||
this->poll_();
|
||||
EXPECT_EQ(callbacks, 1);
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ClearEventFlagPreservesOtherFlagsAndRetriesAfterFailure) {
|
||||
this->bus_.regs[FLAG] = 0x1C;
|
||||
this->bus_.fail_write = FLAG;
|
||||
EXPECT_FALSE(this->rtc_.clear_event_flag());
|
||||
EXPECT_TRUE(this->event_clear_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x1C);
|
||||
this->bus_.fail_write = -1;
|
||||
EXPECT_TRUE(this->rtc_.clear_event_flag());
|
||||
EXPECT_FALSE(this->event_clear_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x18);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, EventFlagClearDoesNotHideBootConfigurationFailure) {
|
||||
this->rtc_.set_event_config(0xC4);
|
||||
this->bus_.fail_write = EVIN;
|
||||
this->rtc_.setup();
|
||||
ASSERT_TRUE(this->event_warning_());
|
||||
this->bus_.fail_write = FLAG;
|
||||
EXPECT_FALSE(this->rtc_.clear_event_flag());
|
||||
EXPECT_TRUE(this->event_clear_warning_());
|
||||
this->bus_.fail_write = EVIN;
|
||||
EXPECT_TRUE(this->rtc_.clear_event_flag());
|
||||
EXPECT_FALSE(this->event_clear_warning_());
|
||||
EXPECT_TRUE(this->event_warning_());
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_FALSE(this->event_warning_());
|
||||
EXPECT_FALSE(this->event_clear_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ExplicitEventDisableDoesNotChangeCaptureOrFlag) {
|
||||
this->bus_.regs[EXTENSION] = 0x04;
|
||||
this->bus_.regs[CONTROL] = 0x0C;
|
||||
this->bus_.regs[FLAG] = 0x04;
|
||||
this->rtc_.set_event_disabled();
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{CONTROL}));
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL], 0x08);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0x04);
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x04);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, BatteryAndFoutPreserveUnrelatedBitsAndTimerRouting) {
|
||||
this->bus_.regs[POWER_SWITCH] = 0xB5;
|
||||
this->bus_.regs[EXTENSION] = 0xDA;
|
||||
this->rtc_.set_battery_backup(true, false);
|
||||
this->rtc_.set_fout_frequency(1);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0x79);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0x5A);
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{POWER_SWITCH, EXTENSION}));
|
||||
EXPECT_FALSE(this->power_warning_());
|
||||
EXPECT_FALSE(this->fout_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ChargingRequiresExplicitEnableAndCanBeDisabled) {
|
||||
this->bus_.regs[POWER_SWITCH] = 0x15;
|
||||
this->rtc_.set_battery_backup(true, true);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0xD9);
|
||||
this->rtc_.set_battery_backup(false, false);
|
||||
this->configure_battery_();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0x19);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FoutRefusesTimerPinRoutingWithoutModifyingIt) {
|
||||
this->bus_.regs[TIMER_CONTROL] = 0x02;
|
||||
this->bus_.regs[EXTENSION] = 0xC5;
|
||||
this->rtc_.set_fout_frequency(0);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC5);
|
||||
EXPECT_TRUE(this->fout_warning_());
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
this->bus_.regs[TIMER_CONTROL] = 0;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0x05);
|
||||
EXPECT_FALSE(this->fout_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, BootTimerClearingTimerPinRoutingLetsFoutFinishAfterItsRetriesRanOut) {
|
||||
this->bus_.regs[TIMER_CONTROL] = 0x02;
|
||||
this->bus_.regs[EXTENSION] = 0xC0;
|
||||
this->bus_.fail_write = TIMER_CONTROL;
|
||||
this->rtc_.set_fout_frequency(0);
|
||||
this->rtc_.set_timer_count(2);
|
||||
this->rtc_.set_timer_configured(true);
|
||||
this->rtc_.set_timer_enabled(true);
|
||||
this->rtc_.setup();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->fout_warning_());
|
||||
this->bus_.fail_write = -1;
|
||||
// The last FOUT attempt runs before the last timer attempt and still sees TMPIN
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0xC0, 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10);
|
||||
EXPECT_FALSE(this->fout_warning_());
|
||||
EXPECT_FALSE(this->timer_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, RuntimeTimerStartClearingTimerPinRoutingLetsFoutFinish) {
|
||||
this->bus_.regs[TIMER_CONTROL] = 0x02;
|
||||
this->bus_.regs[EXTENSION] = 0xC0;
|
||||
this->rtc_.set_fout_frequency(0);
|
||||
this->rtc_.set_timer_count(2);
|
||||
this->rtc_.setup();
|
||||
this->advance_retry_();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->fout_warning_());
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0xC0, 0xC0);
|
||||
ASSERT_TRUE(this->rtc_.start_timer());
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0xC0, 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10);
|
||||
EXPECT_FALSE(this->fout_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, PowerWriteFailureRetriesAndLeavesWarningOnExhaustion) {
|
||||
this->bus_.regs[POWER_SWITCH] = 0x85;
|
||||
this->bus_.fail_write = POWER_SWITCH;
|
||||
this->rtc_.set_battery_backup(true, false);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0x85);
|
||||
EXPECT_TRUE(this->power_warning_());
|
||||
this->advance_retry_();
|
||||
this->advance_retry_();
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0x85);
|
||||
EXPECT_TRUE(this->power_warning_());
|
||||
this->configure_battery_();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], 0x49);
|
||||
EXPECT_FALSE(this->power_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ExplicitBatteryConfigurationNormalizesEverySwitchSelection) {
|
||||
for (uint8_t selection = 0; selection < 4; selection++) {
|
||||
for (bool enabled : {false, true}) {
|
||||
this->bus_.regs[POWER_SWITCH] = 0xB3 | (selection << 2);
|
||||
this->rtc_.set_battery_backup(enabled, false);
|
||||
this->configure_battery_();
|
||||
EXPECT_EQ(this->bus_.regs[POWER_SWITCH], enabled ? 0x7B : 0x3B);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, TimestampSelectionFailureRetriesBeforeEnablingDetection) {
|
||||
this->bus_.regs[TIMESTAMP_CONTROL] = 0x05;
|
||||
this->bus_.fail_write = TIMESTAMP_CONTROL;
|
||||
this->rtc_.set_event_config(0);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x04, 0);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[TIMESTAMP_CONTROL], 0x01);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x04, 0x04);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x04, 0x04);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, ExplicitTimerStartNormalizesRetainedModeAtBootAndRuntime) {
|
||||
this->rtc_.set_timer_count(2);
|
||||
this->rtc_.set_timer_configured(true);
|
||||
this->rtc_.set_timer_enabled(true);
|
||||
this->bus_.regs[TIMER_CONTROL] = 0xFF;
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0xF0);
|
||||
for (uint8_t mode : {0x01, 0x02, 0x04, 0x08, 0x0C, 0x0F}) {
|
||||
this->bus_.regs[TIMER_CONTROL] = 0xF0 | mode;
|
||||
ASSERT_TRUE(this->rtc_.start_timer());
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0xF0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, TimerModeFailureDoesNotStartCounterAndBootRetries) {
|
||||
this->rtc_.set_timer_count(2);
|
||||
this->rtc_.set_timer_configured(true);
|
||||
this->rtc_.set_timer_enabled(true);
|
||||
this->bus_.regs[TIMER_CONTROL] = 1;
|
||||
this->bus_.fail_write = TIMER_CONTROL;
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10);
|
||||
this->bus_.fail_write = TIMER_CONTROL;
|
||||
EXPECT_FALSE(this->rtc_.start_timer());
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
ASSERT_TRUE(this->rtc_.start_timer());
|
||||
EXPECT_EQ(this->bus_.regs[TIMER_CONTROL], 0);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FailedRestartKeepsStopAcrossHostResetUntilFreshTrustedWrite) {
|
||||
this->bus_.fail_control_write_number = 2;
|
||||
this->rtc_.write_time();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0);
|
||||
this->bus_.writes.clear();
|
||||
this->bus_.fail_write = CONTROL;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->bus_.fail_write = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0); // Even with initially clear hardware validity flags.
|
||||
RX8111Component rebooted;
|
||||
rebooted.set_i2c_bus(&this->bus_);
|
||||
rebooted.set_i2c_address(ADDRESS);
|
||||
rebooted.add_on_time_sync_callback([this]() { this->syncs_++; });
|
||||
rebooted.setup();
|
||||
EXPECT_EQ(this->syncs_, 0); // The RAM guard is gone, but STOP survives.
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0); // Even with validity flags already cleared.
|
||||
rebooted.write_time();
|
||||
rebooted.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
this->rtc_.write_time();
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 2);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FailedCalendarWriteKeepsStopAndDoesNotRetry) {
|
||||
this->bus_.regs[FLAG] = 0x83;
|
||||
this->bus_.fail_write = SEC;
|
||||
this->bus_.fail_control_write_number = 2;
|
||||
this->rtc_.write_time();
|
||||
this->bus_.fail_write = -1;
|
||||
this->bus_.writes.clear();
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x83);
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FailedCalendarWriteRejectsPreviouslyValidCalendarUntilFreshWrite) {
|
||||
this->rtc_.write_time();
|
||||
ASSERT_EQ(this->bus_.regs[FLAG], 0);
|
||||
this->bus_.fail_write = SEC;
|
||||
this->rtc_.write_time();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0);
|
||||
this->bus_.fail_write = -1;
|
||||
this->rtc_.write_time();
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, IncompleteWritesRemainInvalidAfterComponentRecreation) {
|
||||
for (const uint8_t failed_register : {SEC, FLAG, CONTROL}) {
|
||||
this->bus_.fail_write = -1;
|
||||
this->rtc_.write_time();
|
||||
ASSERT_EQ(this->bus_.regs[FLAG], 0);
|
||||
this->bus_.fail_write = failed_register == CONTROL ? -1 : failed_register;
|
||||
this->bus_.fail_control_write_number = failed_register == CONTROL ? this->bus_.control_writes + 2 : -1;
|
||||
this->rtc_.write_time();
|
||||
ASSERT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
this->bus_.fail_write = -1;
|
||||
RX8111Component rebooted;
|
||||
rebooted.set_i2c_bus(&this->bus_);
|
||||
rebooted.set_i2c_address(ADDRESS);
|
||||
unsigned syncs = 0;
|
||||
rebooted.add_on_time_sync_callback([&syncs]() { syncs++; });
|
||||
rebooted.setup();
|
||||
rebooted.read_time();
|
||||
EXPECT_EQ(syncs, 0) << "failed register " << unsigned(failed_register);
|
||||
rebooted.write_time();
|
||||
rebooted.read_time();
|
||||
EXPECT_EQ(syncs, 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, PersistentControlFailureDoesNotRearmRecovery) {
|
||||
this->bus_.fail_control_write_number = 2;
|
||||
this->rtc_.write_time();
|
||||
this->bus_.fail_write = CONTROL;
|
||||
const auto control_writes = this->bus_.control_writes;
|
||||
for (unsigned i = 0; i < 10; i++)
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.control_writes, control_writes);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, NewTrustedWriteRestartsStoppedCounterWithoutDeferredRecovery) {
|
||||
this->bus_.fail_control_write_number = 2;
|
||||
this->rtc_.write_time();
|
||||
ASSERT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
this->rtc_.write_time();
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0);
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
this->bus_.writes.clear();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FailedValidityCleanupRequiresFreshWriteAndPreservesOtherFaults) {
|
||||
this->bus_.regs[FLAG] = 0x83;
|
||||
this->bus_.fail_write = FLAG;
|
||||
this->rtc_.write_time();
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
this->bus_.fail_write = POWER_SWITCH;
|
||||
this->rtc_.set_battery_backup(false, false);
|
||||
this->configure_battery_();
|
||||
this->bus_.writes.clear();
|
||||
this->advance_retry_();
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x83);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->bus_.fail_write = -1;
|
||||
this->configure_battery_();
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0);
|
||||
this->rtc_.write_time();
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, FoutReadFailureDoesNotAlterExtensionAndRecovers) {
|
||||
this->bus_.regs[EXTENSION] = 0xC5;
|
||||
this->bus_.fail_read = EXTENSION;
|
||||
this->rtc_.set_fout_frequency(3);
|
||||
this->rtc_.setup();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC5);
|
||||
EXPECT_TRUE(this->fout_warning_());
|
||||
this->bus_.fail_read = -1;
|
||||
this->advance_retry_();
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC5);
|
||||
EXPECT_FALSE(this->fout_warning_());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) {
|
||||
this->bus_.regs[FLAG] = 0xB8; // POR + existing UF/TF/AF.
|
||||
this->bus_.regs[CONTROL] = 0x5C;
|
||||
@@ -614,7 +111,7 @@ TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) {
|
||||
(this->bus_.regs[SEC] == bcd(after.second) && this->bus_.regs[DAY] == bcd(after.day_of_month)));
|
||||
EXPECT_TRUE(this->bus_.regs[WEEK] == (1 << (before.day_of_week - 1)) ||
|
||||
this->bus_.regs[WEEK] == (1 << (after.day_of_week - 1)));
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{CONTROL, SEC, FLAG, CONTROL}));
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{CONTROL, SEC, CONTROL, FLAG}));
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) {
|
||||
@@ -657,7 +154,7 @@ TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) {
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->bus_.regs[SEC] = 0x60; // Hardware supports leap adjustment; synchronization requires normal seconds.
|
||||
this->bus_.regs[SEC] = 0x60; // ESPTime permits leap seconds, but RX8111 only counts 00..59.
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
@@ -667,27 +164,7 @@ TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) {
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, CalendarReadCombinesFlagAndControlAndRejectsReadFailures) {
|
||||
this->rtc_.write_time();
|
||||
this->bus_.reads.clear();
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
EXPECT_EQ(this->bus_.reads, (std::vector<std::pair<uint8_t, size_t>>{{FLAG, 2}, {SEC, 7}}));
|
||||
for (const uint8_t failed_register : {FLAG, CONTROL}) {
|
||||
this->bus_.reads.clear();
|
||||
this->bus_.fail_read = failed_register;
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 1);
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
EXPECT_EQ(this->bus_.reads, (std::vector<std::pair<uint8_t, size_t>>{{FLAG, 2}}));
|
||||
}
|
||||
this->bus_.fail_read = -1;
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 2);
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, I2CFailuresDoNotSyncAndIncompleteWritesLeaveCounterStopped) {
|
||||
TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) {
|
||||
this->bus_.fail_read = FLAG;
|
||||
this->rtc_.read_time();
|
||||
EXPECT_EQ(this->syncs_, 0);
|
||||
@@ -701,13 +178,13 @@ TEST_F(RX8111Test, I2CFailuresDoNotSyncAndIncompleteWritesLeaveCounterStopped) {
|
||||
EXPECT_TRUE(this->bus_.writes.empty());
|
||||
this->bus_.fail_write = SEC;
|
||||
this->rtc_.write_time();
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{CONTROL}));
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{CONTROL, CONTROL}));
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0);
|
||||
this->bus_.fail_write = FLAG;
|
||||
this->bus_.regs[FLAG] = 0x83;
|
||||
this->rtc_.write_time();
|
||||
EXPECT_EQ(this->bus_.regs[FLAG], 0x83);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 1);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0);
|
||||
}
|
||||
|
||||
TEST_F(RX8111Test, WriteFailureClearsOnSuccessfulWriteButPreservesEventPollWarning) {
|
||||
@@ -1213,8 +690,7 @@ TEST_F(RX8111Test, StartTimerRestartsFullCountAndClearsOnlyStaleTimerFlag) {
|
||||
EXPECT_EQ(this->bus_.regs[0x1C], 0);
|
||||
EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10);
|
||||
EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0x10);
|
||||
EXPECT_EQ(this->bus_.writes,
|
||||
(std::vector<uint8_t>{EXTENSION, CONTROL, TIMER_CONTROL, 0x1A, EXTENSION, FLAG, CONTROL, EXTENSION}));
|
||||
EXPECT_EQ(this->bus_.writes, (std::vector<uint8_t>{EXTENSION, CONTROL, 0x1A, EXTENSION, FLAG, CONTROL, EXTENSION}));
|
||||
this->rtc_.update();
|
||||
EXPECT_EQ(calls, 0);
|
||||
this->bus_.regs[FLAG] |= 0x10;
|
||||
@@ -1528,11 +1004,9 @@ TEST_F(RX8111Test, RetainedAlarmInspectionRetriesAndDeliversWakeFlag) {
|
||||
this->rtc_.add_on_alarm_callback([&calls]() { calls++; });
|
||||
this->bus_.regs[FLAG] = 0x08;
|
||||
this->advance_retry_();
|
||||
// Alarm recovery cannot clear the calendar's independent failed CONTROL-read warning.
|
||||
EXPECT_TRUE(this->rtc_.status_has_warning());
|
||||
this->rtc_.update();
|
||||
EXPECT_FALSE(this->rtc_.status_has_warning());
|
||||
this->rtc_.update();
|
||||
this->rtc_.update();
|
||||
EXPECT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,17 +41,6 @@ esphome:
|
||||
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
|
||||
target_temperature: !lambda "return 23.0f;"
|
||||
|
||||
- climate.template.publish:
|
||||
id: template_climate_fahrenheit
|
||||
current_temperature: 20°C
|
||||
target_temperature: 70
|
||||
- climate.control:
|
||||
id: template_climate_fahrenheit
|
||||
target_temperature: 22°C
|
||||
- water_heater.template.publish:
|
||||
id: template_water_heater_fahrenheit
|
||||
current_temperature: 45°C
|
||||
target_temperature: 120
|
||||
- climate.template.publish:
|
||||
id: template_climate_custom_modes
|
||||
custom_fan_mode: "turbo"
|
||||
@@ -663,25 +652,6 @@ alarm_control_panel:
|
||||
- "1234"
|
||||
|
||||
climate:
|
||||
# unit_of_measurement keeps the visual temperatures in that unit; a value written in
|
||||
# another unit is converted into it
|
||||
- platform: template
|
||||
id: template_climate_fahrenheit
|
||||
name: "Template Climate Fahrenheit"
|
||||
unit_of_measurement: °F
|
||||
optimistic: true
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
initial_state:
|
||||
mode: HEAT
|
||||
target_temperature: 21°C
|
||||
visual:
|
||||
min_temperature: 60
|
||||
max_temperature: 30°C
|
||||
temperature_step:
|
||||
target_temperature: 1°F
|
||||
current_temperature: 0.5°C
|
||||
- platform: template
|
||||
id: template_climate
|
||||
name: "Template Climate"
|
||||
@@ -774,17 +744,6 @@ climate:
|
||||
temperature_step: 0.5
|
||||
|
||||
water_heater:
|
||||
- platform: template
|
||||
id: template_water_heater_fahrenheit
|
||||
name: "Template Water Heater Fahrenheit"
|
||||
unit_of_measurement: °F
|
||||
optimistic: true
|
||||
current_temperature: !lambda "return 110.0f;"
|
||||
target_temperature: !lambda "return 120.0f;"
|
||||
visual:
|
||||
min_temperature: 100
|
||||
max_temperature: 60°C
|
||||
target_temperature_step: 1°C
|
||||
- platform: template
|
||||
id: template_water_heater
|
||||
name: "Template Water Heater"
|
||||
|
||||
@@ -2,11 +2,15 @@ water_heater:
|
||||
- platform: template
|
||||
id: my_boiler
|
||||
name: "Test Boiler"
|
||||
min_temperature: 10
|
||||
max_temperature: 85
|
||||
target_temperature_step: 0.5
|
||||
current_temperature_step: 0.1
|
||||
optimistic: true
|
||||
current_temperature: !lambda "return 45.0f;"
|
||||
target_temperature: !lambda "return 60.0f;"
|
||||
current_temperature: 45.0
|
||||
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
|
||||
visual:
|
||||
min_temperature: 10
|
||||
max_temperature: 85
|
||||
target_temperature_step: 0.5
|
||||
current_temperature_step: 0.1
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
water_heater: !include common.yaml
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
water_heater: !include common.yaml
|
||||
@@ -1,15 +0,0 @@
|
||||
# The access point stays up next to the station, so the captive mode follows the
|
||||
# station: ended when it connects, served again while it is down.
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: MySSID
|
||||
password: password1
|
||||
# A roaming scan would take the AP down, so ALWAYS_ON requires it off
|
||||
post_connect_roaming: false
|
||||
ap:
|
||||
ssid: "ESPHome-Test"
|
||||
password: "Test1234!"
|
||||
ap_mode: ALWAYS_ON
|
||||
|
||||
web_server:
|
||||
local: true
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
web_server: !include common-ap-always-on.yaml
|
||||
@@ -1,17 +0,0 @@
|
||||
# ap_mode: WISP with captive_portal: the bridge may start before the upstream has a
|
||||
# resolver, offering its own address so lookups reach the portal, and serves the
|
||||
# portal while the station is down.
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: WISP AP
|
||||
password: wisp12345678
|
||||
ap_mode: WISP
|
||||
|
||||
# Closing the provisioning window ends the portal and leaves the bridge up.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
|
||||
captive_portal:
|
||||
@@ -3,9 +3,6 @@ wifi:
|
||||
ssid: WISP AP
|
||||
password: wisp12345678
|
||||
ap_mode: wisp
|
||||
# Static resolvers; the portal variant covers the ones passed on from the upstream
|
||||
dns_servers:
|
||||
- 1.1.1.1
|
||||
|
||||
packages:
|
||||
wifi: !include common.yaml
|
||||
@@ -1,25 +0,0 @@
|
||||
# Ethernet can be the upstream of an AP that stays up: no station credentials are
|
||||
# needed, and with nothing to roam to the default post_connect_roaming is fine.
|
||||
esphome:
|
||||
name: validate-always-on-ethernet
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
ethernet:
|
||||
type: LAN8720
|
||||
mdc_pin: 23
|
||||
mdio_pin: 32
|
||||
clk:
|
||||
pin: 0
|
||||
mode: CLK_EXT_IN
|
||||
phy_addr: 0
|
||||
power_pin: 33
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: Always on AP
|
||||
password: always_on_password
|
||||
ap_mode: ALWAYS_ON
|
||||
@@ -361,23 +361,10 @@ def test_wisp_requires_sta_network_or_upstream() -> None:
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("wisp_core")
|
||||
def test_wisp_accepts_captive_portal() -> None:
|
||||
"""The portal is served on the bridge while the upstream is down."""
|
||||
_final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("wisp_core")
|
||||
def test_wisp_portal_rejects_dns_servers() -> None:
|
||||
"""Clients only find the portal while DHCP offers the AP's own address as resolver."""
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="dns_servers cannot be combined with captive_portal"
|
||||
):
|
||||
def test_wisp_rejects_captive_portal() -> None:
|
||||
with pytest.raises(cv.Invalid, match="captive_portal"):
|
||||
_final_validate(
|
||||
{
|
||||
CONF_NETWORKS: [{}],
|
||||
CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}),
|
||||
},
|
||||
{"captive_portal": {}},
|
||||
{CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,11 +31,9 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
UNRESOLVED_PROPERTY,
|
||||
_check_report_deprecation,
|
||||
_register_string,
|
||||
_setup_entity_impl,
|
||||
convert_visual_temperatures,
|
||||
entity_duplicate_validator,
|
||||
finalize_entity_strings,
|
||||
get_base_entity_object_id,
|
||||
@@ -45,7 +43,6 @@ from esphome.core.entity_helpers import (
|
||||
register_device_class,
|
||||
register_icon,
|
||||
register_unit_of_measurement,
|
||||
resolve_inherited_property,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
setup_entity_integrations,
|
||||
@@ -53,7 +50,6 @@ from esphome.core.entity_helpers import (
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass, RawExpression
|
||||
from esphome.helpers import fnv1_hash, sanitize, snake_case
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .common import load_config_from_fixture
|
||||
|
||||
@@ -1420,45 +1416,6 @@ async def test_sub_entities_without_parent(
|
||||
assert setup_test_environment == ["hub->set_child(child)"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("delta_keys", "delta_section"),
|
||||
[(("step",), None), (("target", "current"), "steps")],
|
||||
ids=["flat step", "nested steps"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("unit", "expected_min", "expected_step"),
|
||||
[(None, 10.0, 1.0), ("°F", 50.0, 1.8)],
|
||||
ids=["celsius entity", "fahrenheit entity"],
|
||||
)
|
||||
def test_convert_visual_temperatures(
|
||||
delta_keys: tuple[str, ...],
|
||||
delta_section: str | None,
|
||||
unit: str | None,
|
||||
expected_min: float,
|
||||
expected_step: float,
|
||||
) -> None:
|
||||
"""Absolute values and the step differences land in the entity's unit, whatever the key shape."""
|
||||
steps = dict.fromkeys(delta_keys, (1.0, "°C"))
|
||||
visual = {"min_temperature": (50.0, "°F"), "max_temperature": (30.0, None)}
|
||||
if delta_section is None:
|
||||
visual.update(steps)
|
||||
else:
|
||||
visual[delta_section] = steps
|
||||
config = {"visual": visual}
|
||||
if unit is not None:
|
||||
config["unit_of_measurement"] = cv.temperature_unit(unit)
|
||||
|
||||
convert_visual_temperatures(config, delta_keys, delta_section)
|
||||
|
||||
assert visual["min_temperature"] == pytest.approx(expected_min)
|
||||
assert visual["max_temperature"] == 30.0
|
||||
section = visual if delta_section is None else visual[delta_section]
|
||||
assert all(section[key] == pytest.approx(expected_step) for key in delta_keys)
|
||||
|
||||
# A domain whose steps are nested may leave them out
|
||||
convert_visual_temperatures({"visual": {}}, delta_keys, delta_section)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_integrations_registers_mqtt_and_web_server(
|
||||
monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str]
|
||||
@@ -1939,104 +1896,6 @@ def test_inherit_property_from_path_missing_is_ignored() -> None:
|
||||
assert "absent" not in config["sensor"][1]
|
||||
|
||||
|
||||
def _copy_chain_config(*sources: str | None) -> Config:
|
||||
"""sensor.0 is a template sensor in °C; sensor.N is a copy of the sensor its source names."""
|
||||
config = Config()
|
||||
config["sensor"] = [
|
||||
{CONF_ID: ID("s0", is_declaration=True), CONF_UNIT_OF_MEASUREMENT: "°C"}
|
||||
]
|
||||
config.declare_ids.append((config["sensor"][0][CONF_ID], ["sensor", 0, CONF_ID]))
|
||||
for index, source in enumerate(sources, start=1):
|
||||
sensor_id = ID(f"s{index}", is_declaration=True)
|
||||
config["sensor"].append(
|
||||
{CONF_ID: sensor_id, "platform": "copy", "source_id": ID(source)}
|
||||
)
|
||||
config.declare_ids.append((sensor_id, ["sensor", index, CONF_ID]))
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sources", "entity", "expected"),
|
||||
[
|
||||
(("s0",), "s1", "°C"),
|
||||
(("s0", "s1"), "s2", "°C"),
|
||||
(("s2", "s1"), "s2", UNRESOLVED_PROPERTY),
|
||||
],
|
||||
ids=["copy", "copy of copy", "cycle"],
|
||||
)
|
||||
def test_resolve_inherited_property_follows_the_inherit_chain(
|
||||
sources: tuple[str, ...], entity: str, expected: str | object | None
|
||||
) -> None:
|
||||
"""The unit is found before the copy platform's own final validation has run."""
|
||||
token = fv.full_config.set(_copy_chain_config(*sources))
|
||||
try:
|
||||
assert (
|
||||
resolve_inherited_property(ID(entity), CONF_UNIT_OF_MEASUREMENT) == expected
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def _declare(config: Config, path: list[str | int], entry: ConfigType) -> None:
|
||||
"""Place ``entry`` at ``path`` and declare its id there."""
|
||||
parent = config
|
||||
for key in path[:-1]:
|
||||
parent = parent[key]
|
||||
parent[path[-1]] = entry
|
||||
config.declare_ids.append((entry[CONF_ID], [*path, CONF_ID]))
|
||||
|
||||
|
||||
def test_resolve_inherited_property_applies_the_transform() -> None:
|
||||
"""An integration of a sensor in W reports in Wh, as its final validation would set it."""
|
||||
config = Config()
|
||||
config["sensor"] = [None, None]
|
||||
_declare(
|
||||
config,
|
||||
["sensor", 0],
|
||||
{CONF_ID: ID("power", is_declaration=True), CONF_UNIT_OF_MEASUREMENT: "W"},
|
||||
)
|
||||
_declare(
|
||||
config,
|
||||
["sensor", 1],
|
||||
{
|
||||
CONF_ID: ID("energy", is_declaration=True),
|
||||
"platform": "integration",
|
||||
"sensor": ID("power"),
|
||||
"time_unit": "h",
|
||||
},
|
||||
)
|
||||
token = fv.full_config.set(config)
|
||||
try:
|
||||
assert (
|
||||
resolve_inherited_property(ID("energy"), CONF_UNIT_OF_MEASUREMENT) == "Wh"
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[["no_such_component"], ["hub", 0, "temperature"]],
|
||||
ids=["unknown component", "nested entity without platform"],
|
||||
)
|
||||
def test_resolve_inherited_property_cannot_follow_the_chain(
|
||||
path: list[str | int],
|
||||
) -> None:
|
||||
"""Unresolved, not None, when no component tells where the property would come from."""
|
||||
config = Config()
|
||||
if len(path) > 1:
|
||||
config[path[0]] = [{}]
|
||||
_declare(config, path, {CONF_ID: ID("entity", is_declaration=True)})
|
||||
token = fv.full_config.set(config)
|
||||
try:
|
||||
assert (
|
||||
resolve_inherited_property(ID("entity"), CONF_UNIT_OF_MEASUREMENT)
|
||||
is UNRESOLVED_PROPERTY
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
_RESTORE_FIELDS = cv.Schema({cv.Optional("state", default="KEEP"): cv.string})
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -51,9 +51,6 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_FAHRENHEIT,
|
||||
UNIT_KELVIN,
|
||||
Framework,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -2236,65 +2233,6 @@ def test_temperature_delta_invalid() -> None:
|
||||
cv.temperature_delta("5x")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("100°F", (100.0, UNIT_FAHRENHEIT)),
|
||||
("100 °F", (100.0, UNIT_FAHRENHEIT)),
|
||||
("100F", (100.0, UNIT_FAHRENHEIT)),
|
||||
("25°C", (25.0, UNIT_CELSIUS)),
|
||||
("25C", (25.0, UNIT_CELSIUS)),
|
||||
("300K", (300.0, UNIT_KELVIN)),
|
||||
("300°K", (300.0, UNIT_KELVIN)),
|
||||
("25", (25.0, None)),
|
||||
("25°", (25.0, None)),
|
||||
(25, (25.0, None)),
|
||||
],
|
||||
)
|
||||
def test_temperature_with_unit(
|
||||
value: str | int, expected: tuple[float, str | None]
|
||||
) -> None:
|
||||
assert cv.temperature_with_unit(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["5x", "abc°F", None])
|
||||
def test_temperature_with_unit__invalid(value: str | None) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
cv.temperature_with_unit(value)
|
||||
|
||||
|
||||
def test_temperature_unit() -> None:
|
||||
assert cv.temperature_unit("°F") == UNIT_FAHRENHEIT
|
||||
with pytest.raises(Invalid):
|
||||
cv.temperature_unit("F")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "unit", "to_unit", "delta", "expected"),
|
||||
[
|
||||
(60.0, None, UNIT_FAHRENHEIT, False, 60.0),
|
||||
(30.0, UNIT_CELSIUS, UNIT_FAHRENHEIT, False, 86.0),
|
||||
(212.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, False, 100.0),
|
||||
(300.0, UNIT_KELVIN, UNIT_FAHRENHEIT, False, 80.33),
|
||||
(86.0, UNIT_FAHRENHEIT, UNIT_KELVIN, False, 303.15),
|
||||
(0.5, UNIT_CELSIUS, UNIT_FAHRENHEIT, True, 0.9),
|
||||
(9.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, True, 5.0),
|
||||
(1.0, UNIT_KELVIN, UNIT_CELSIUS, True, 1.0),
|
||||
],
|
||||
)
|
||||
def test_convert_temperature(
|
||||
value: float, unit: str | None, to_unit: str, delta: bool, expected: float
|
||||
) -> None:
|
||||
assert cv.convert_temperature(value, unit, to_unit, delta=delta) == pytest.approx(
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def test_convert_temperature_rejects_unknown_unit() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
cv.convert_temperature(1.0, "R", UNIT_CELSIUS)
|
||||
|
||||
|
||||
def test_color_temperature_mireds() -> None:
|
||||
assert cv.color_temperature("153 mireds") == pytest.approx(153.0)
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Tests for esphome.components.nrf52 upload_program and run_compile."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -519,130 +518,3 @@ class TestUploadProgramSerialDfu:
|
||||
mock_run.return_value = False
|
||||
with pytest.raises(EsphomeError, match="serial DFU upload failed"):
|
||||
upload_program(config={}, args=None, host=host)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BLE OTA upload path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BLE_MAC = "AA:BB:CC:DD:EE:FF"
|
||||
DFU_PACKAGE = object()
|
||||
|
||||
|
||||
class TestUploadProgramBle:
|
||||
"""The BLE path uses the OTA protocol the firmware was built with."""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
host: str = BLE_MAC,
|
||||
ota_platform: str = "adafruit_ble",
|
||||
) -> tuple[AsyncMock, AsyncMock, AsyncMock, AsyncMock]:
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
# The validated config cache skips validation, so nothing is loaded
|
||||
CORE.loaded_integrations = set()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.nrf52.ota.smpmgr_scan",
|
||||
new=AsyncMock(return_value=BLE_MAC),
|
||||
) as mock_scan,
|
||||
patch(
|
||||
"esphome.components.adafruit_ble.dfu.enter_dfu_mode",
|
||||
new=AsyncMock(return_value="AA:BB:CC:DD:EE:00"),
|
||||
) as mock_enter,
|
||||
patch(
|
||||
"esphome.components.adafruit_ble.dfu.load_firmware",
|
||||
return_value=DFU_PACKAGE,
|
||||
),
|
||||
patch(
|
||||
"esphome.components.adafruit_ble.dfu.dfu_upload", new=AsyncMock()
|
||||
) as mock_dfu,
|
||||
patch(
|
||||
"esphome.components.nrf52._smpmgr_upload", new=AsyncMock()
|
||||
) as mock_smp,
|
||||
):
|
||||
config = {"ota": [{"platform": ota_platform}]}
|
||||
assert upload_program(config=config, args=None, host=host) is True
|
||||
return mock_scan, mock_enter, mock_dfu, mock_smp
|
||||
|
||||
@pytest.mark.parametrize("host", [BLE_MAC, "BLE"])
|
||||
def test_adafruit_ble_uploads_to_bootloader(
|
||||
self, setup_core: Path, tmp_path: Path, host: str
|
||||
) -> None:
|
||||
"""The application reboots into the bootloader, which receives the package.
|
||||
|
||||
The choice comes from the config, so it holds on the validated config
|
||||
cache path where loaded_integrations stays empty.
|
||||
"""
|
||||
mock_scan, mock_enter, mock_dfu, mock_smp = self._run(tmp_path, host)
|
||||
|
||||
assert mock_scan.await_count == (host == "BLE")
|
||||
mock_enter.assert_awaited_once_with(BLE_MAC)
|
||||
mock_dfu.assert_awaited_once_with("AA:BB:CC:DD:EE:00", DFU_PACKAGE)
|
||||
mock_smp.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize("host", [BLE_MAC, "BLE"])
|
||||
def test_adafruit_ble_checks_package_before_reboot(
|
||||
self, setup_core: Path, tmp_path: Path, host: str
|
||||
) -> None:
|
||||
"""A missing package fails before the device is asked to enter DFU mode."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.nrf52.ota.smpmgr_scan",
|
||||
new=AsyncMock(return_value=BLE_MAC),
|
||||
) as mock_scan,
|
||||
patch(
|
||||
"esphome.components.adafruit_ble.dfu.enter_dfu_mode", new=AsyncMock()
|
||||
) as mock_enter,
|
||||
pytest.raises(EsphomeError, match="DFU package not found"),
|
||||
):
|
||||
upload_program(
|
||||
config={"ota": [{"platform": "adafruit_ble"}]}, args=None, host=host
|
||||
)
|
||||
mock_scan.assert_not_awaited()
|
||||
mock_enter.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize("host", [BLE_MAC, "BLE"])
|
||||
def test_mcumgr_uploads_over_smp(
|
||||
self, setup_core: Path, tmp_path: Path, host: str
|
||||
) -> None:
|
||||
"""Without adafruit_ble the image goes over MCUmgr, on either bootloader."""
|
||||
mock_scan, mock_enter, mock_dfu, mock_smp = self._run(
|
||||
tmp_path, host, "zephyr_mcumgr"
|
||||
)
|
||||
|
||||
assert mock_scan.await_count == (host == "BLE")
|
||||
mock_smp.assert_awaited_once_with(BLE_MAC)
|
||||
mock_enter.assert_not_awaited()
|
||||
mock_dfu.assert_not_awaited()
|
||||
|
||||
|
||||
class TestUploadOverBle:
|
||||
def test_name_scan_miss_hints_at_bootloader_address(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A device stuck in its bootloader does not advertise its name."""
|
||||
from esphome.components.adafruit_ble import dfu
|
||||
from esphome.components.nrf52 import _upload_over_ble
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
with (
|
||||
patch("esphome.components.adafruit_ble.dfu.load_firmware"),
|
||||
patch(
|
||||
"esphome.components.nrf52.ota.smpmgr_scan",
|
||||
AsyncMock(side_effect=EsphomeError("BLE device test_device not found")),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="test_device not found") as err,
|
||||
):
|
||||
asyncio.run(
|
||||
_upload_over_ble({"ota": [{"platform": "adafruit_ble"}]}, "BLE")
|
||||
)
|
||||
assert str(err.value).endswith(dfu.BOOTLOADER_HINT)
|
||||
@@ -2454,18 +2454,6 @@ def test_find_secret_references_in_flow_collections() -> None:
|
||||
assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", ["70°F", "21", "300 K", "0.5°C"])
|
||||
def test_dumped_temperature_validates_again(written: str) -> None:
|
||||
"""A parsed temperature dumps as a scalar with its unit, so `esphome config` output is valid input."""
|
||||
parsed = cv.temperature_with_unit(written)
|
||||
|
||||
dumped = yaml_util.dump({"target": parsed})
|
||||
reloaded = yaml_util.parse_yaml(Path("dump.yaml"), io.StringIO(dumped))
|
||||
|
||||
assert "[" not in dumped
|
||||
assert cv.temperature_with_unit(reloaded["target"]) == parsed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_text", "expected_offset"),
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user