Compare commits

..
98 changed files with 401 additions and 7116 deletions

No files matched your search

-3
View File
@@ -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
+2 -3
View File
@@ -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"
-603
View File
@@ -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
-213
View File
@@ -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()"
)
-310
View File
@@ -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
-138
View File
@@ -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
-198
View File
@@ -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
-131
View File
@@ -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
+24 -77
View File
@@ -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(
+2 -6
View File
@@ -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};
+2 -8
View File
@@ -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)",
)
+2 -5
View File
@@ -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 -5
View File
@@ -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,
+20 -68
View File
@@ -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
+1 -6
View File
@@ -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_{};
};
+1 -15
View File
@@ -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 -11
View File
@@ -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]
+1 -1
View File
@@ -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);
+2 -13
View File
@@ -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:
+6 -144
View File
@@ -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) {
+1 -24
View File
@@ -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
+18 -64
View File
@@ -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
+44 -193
View File
@@ -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;
}
+18 -65
View File
@@ -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
-48
View File
@@ -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))
+16 -53
View File
@@ -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};
};
+3 -32
View File
@@ -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:
+30 -60
View File
@@ -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
+40 -84
View File
@@ -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
+3 -19
View File
@@ -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), &current) != 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), &current) != 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) {
+38 -63
View File
@@ -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)")
-1
View File
@@ -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"
+1 -11
View File
@@ -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,
+3 -3
View File
@@ -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
+8 -170
View File
@@ -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.
-9
View File
@@ -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
-5
View File
@@ -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
-7
View File
@@ -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")
-5
View File
@@ -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:
-159
View File
@@ -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"
-83
View File
@@ -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
+1 -2
View File
@@ -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
-11
View File
@@ -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
+10 -536
View File
@@ -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"
+6 -2
View File
@@ -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
+3 -16
View File
@@ -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 -129
View File
@@ -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)
-12
View File
@@ -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"),
[