Merge pull request #16817 from esphome/bump-2026.5.3

2026.5.3
This commit is contained in:
Jesse Hills
2026-06-05 14:31:04 +12:00
committed by GitHub
12 changed files with 259 additions and 59 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.5.2
PROJECT_NUMBER = 2026.5.3
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29
MODEL_CHARACTERISTIC_UUID = 0x2A24
FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26
# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit.
_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
def uuid_is(uuid: int | str, uuid16: int) -> bool:
"""Return True if a validated UUID refers to the given 16-bit short UUID.
A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an
uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every
representation of the same UUID must be considered equivalent.
"""
if isinstance(uuid, int):
return uuid == uuid16
return uuid.upper() in (
f"{uuid16:04X}",
f"{uuid16:08X}",
f"{uuid16:08X}{_BASE_UUID_SUFFIX}",
)
# Core key to store the global configuration
KEY_NOTIFY_REQUIRED = "notify_required"
KEY_SET_VALUE = "set_value"
@@ -195,7 +215,7 @@ def create_description_cud(char_config):
return char_config
# If the config displays a description, there cannot be a descriptor with the CUD UUID
for desc in char_config[CONF_DESCRIPTORS]:
if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID:
if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID):
raise cv.Invalid(
f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present"
)
@@ -218,7 +238,7 @@ def create_notify_cccd(char_config):
return char_config
# If the CCCD descriptor is already present, return the config
for desc in char_config[CONF_DESCRIPTORS]:
if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID:
if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID):
# Check if the WRITE property is set
if not desc[CONF_WRITE]:
raise cv.Invalid(
@@ -244,7 +264,7 @@ def create_device_information_service(config):
# If there is already a device information service,
# there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties
for service in config[CONF_SERVICES]:
if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
if (
CONF_MODEL in config
or CONF_MANUFACTURER in config
@@ -592,7 +612,7 @@ async def to_code(config):
)
for char_conf in service_config[CONF_CHARACTERISTICS]:
await to_code_characteristic(service_var, char_conf)
if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
cg.add(var.set_device_information_service(service_var))
else:
cg.add(var.enqueue_start_service(service_var))

View File

@@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5";
static constexpr uint32_t BIT_TIME_US = 889;
static constexpr uint8_t NBITS = 14;
static constexpr uint8_t NHALFBITS = NBITS * 2;
void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
static bool toggle = false;
@@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
}
toggle = !toggle;
}
optional<RC5Data> RC5Protocol::decode(RemoteReceiveData src) {
RC5Data out{
.address = 0,
.command = 0,
};
uint8_t field_bit;
if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) {
field_bit = 1;
} else if (src.expect_space(2 * BIT_TIME_US)) {
field_bit = 0;
} else {
return {};
}
if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) ||
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) &&
(((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) ||
((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) {
return {};
}
uint32_t out_data = 0;
for (int bit = NBITS - 4; bit >= 1; bit--) {
if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) {
out_data |= 0 << bit;
} else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) {
out_data |= 1 << bit;
// Expand the runs into half-bit levels (true = mark). Each run is exactly one
// half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else.
//
// halfbits[0] is reserved for the leading half-bit, which is always dropped --
// S1 is 1, so its first half sits at the idle level (at either polarity) and
// merges into the pre-frame idle. Captured half-bits start at index 1.
bool halfbits[NHALFBITS + 2];
uint8_t n = 1;
for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) {
if (src.peek_mark(BIT_TIME_US, i)) {
halfbits[n++] = true;
} else if (src.peek_space(BIT_TIME_US, i)) {
halfbits[n++] = false;
} else if (src.peek_mark(2 * BIT_TIME_US, i)) {
halfbits[n++] = true;
halfbits[n++] = true;
} else if (src.peek_space(2 * BIT_TIME_US, i)) {
halfbits[n++] = false;
halfbits[n++] = false;
} else {
return {};
break;
}
}
if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) {
out_data |= 0;
} else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) {
out_data |= 1;
// Expect a full frame once the leading half is restored: 27 captured halves
// (n == 28) or 26 when the final bit also ends on idle and its trailing half
// is dropped too (n == 27). A dropped edge half is the inverse of its partner
// (a Manchester bit always transitions mid-bit), so reconstruct the leading
// half (always) and the trailing half (only when it was dropped).
if (n != NHALFBITS && n != NHALFBITS - 1) {
return {};
}
halfbits[0] = !halfbits[1];
if (n == NHALFBITS - 1) {
halfbits[n] = !halfbits[n - 1];
}
out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u;
out.address = (out_data >> 6) & 0x1F;
return out;
const bool carrier = halfbits[1];
uint16_t bits = 0;
for (uint8_t i = 0; i < NBITS; i++) {
const bool first = halfbits[2 * i];
const bool second = halfbits[2 * i + 1];
if (first == second) {
return {}; // no midpoint transition -> not a valid Manchester bit
}
bits = (bits << 1) | (second == carrier ? 1 : 0);
}
const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit
return RC5Data{
.address = static_cast<uint8_t>((bits >> 6) & 0x1F),
.command = static_cast<uint8_t>((bits & 0x3F) | (field_bit ? 0 : 0x40)),
};
}
void RC5Protocol::dump(const RC5Data &data) {
ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command);
}

View File

@@ -402,18 +402,21 @@ def _generate_lwipopts_h() -> None:
in the build directory, and a pre-build script injects this directory
into the compiler include path before the framework's own include dir.
"""
from jinja2 import Environment, FileSystemLoader
from jinja2 import Environment
lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS)
if not lwip_defines:
return
template_dir = Path(__file__).parent
jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
keep_trailing_newline=True,
# Read the template via pathlib and render from a string rather than using
# FileSystemLoader. jinja2's loader joins the search path with posixpath, which
# breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are
# not accepted, causing a spurious TemplateNotFound (see issue #16732).
template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text(
encoding="utf-8"
)
template = jinja_env.get_template("lwipopts.h.jinja")
jinja_env = Environment(keep_trailing_newline=True)
template = jinja_env.from_string(template_text)
content = template.render(**lwip_defines)
lwip_dir = CORE.relative_build_path("lwip_override")

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.5.2"
__version__ = "2026.5.3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -16,7 +16,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
from esphome.core import CORE, EsphomeError
from esphome.helpers import write_file_if_changed
from esphome.types import CoreType
@@ -101,6 +101,7 @@ class StorageJSON:
core_platform: str | None = None,
toolchain: str | None = None,
area: str | None = None,
framework_version: str | None = None,
) -> None:
# Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int)
@@ -141,6 +142,8 @@ class StorageJSON:
self.toolchain = toolchain
# The area of the node
self.area = area
# The framework version the build used (for esp32, the resolved ESP-IDF version)
self.framework_version = framework_version
def as_dict(self):
return {
@@ -162,6 +165,7 @@ class StorageJSON:
"core_platform": self.core_platform,
"toolchain": self.toolchain,
"area": self.area,
"framework_version": self.framework_version,
}
def to_json(self):
@@ -173,10 +177,12 @@ class StorageJSON:
@staticmethod
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
hardware = esph.target_platform.upper()
framework_version: str | None = None
if esph.is_esp32:
from esphome.components import esp32
hardware = esp32.get_esp32_variant(esph)
framework_version = str(esp32.idf_version())
return StorageJSON(
storage_version=1,
name=esph.name,
@@ -200,6 +206,7 @@ class StorageJSON:
core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
area=esph.area,
framework_version=framework_version,
)
@staticmethod
@@ -249,6 +256,7 @@ class StorageJSON:
core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
area = storage.get("area")
framework_version = storage.get("framework_version")
return StorageJSON(
storage_version,
name,
@@ -268,6 +276,7 @@ class StorageJSON:
core_platform,
toolchain,
area,
framework_version,
)
@staticmethod
@@ -311,10 +320,24 @@ class StorageJSON:
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
esp32_data = {KEY_VARIANT: self.target_platform}
if self.framework_version:
import esphome.config_validation as cv
try:
esp32_data[KEY_IDF_VERSION] = cv.Version.parse(
self.framework_version
)
except ValueError as err:
raise EsphomeError(
f"Could not parse the framework version "
f"{self.framework_version!r} from {storage_path()}. "
f"Please clean the build files and recompile."
) from err
CORE.data[KEY_ESP32] = esp32_data
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()

View File

@@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
``src_version`` differs, ``build_path`` differs, the build
``toolchain`` differs (e.g. switching between the PlatformIO and
native ESP-IDF toolchains, which produce incompatible build trees),
or a previously loaded integration was removed in *new*. Adding
integrations or changing unrelated fields (friendly name, esphome
version, etc.) does not trigger a clean.
the ``framework`` or ``framework_version`` differs (e.g. switching
arduino <-> esp-idf, or bumping the ESP-IDF version, which also
produce incompatible build trees), or a previously loaded
integration was removed in *new*. Adding integrations or changing
unrelated fields (friendly name, esphome version, etc.) does not
trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local → remote → local
@@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
return True
if old.toolchain != new.toolchain:
return True
if old.framework != new.framework:
return True
if old.framework_version != new.framework_version:
return True
# Check if any components have been removed
return bool(old.loaded_integrations - new.loaded_integrations)

View File

@@ -0,0 +1,47 @@
"""Tests for esp32_ble_server configuration helpers."""
import pytest
from esphome.components.esp32_ble_server import (
CCCD_DESCRIPTOR_UUID,
CUD_DESCRIPTOR_UUID,
DEVICE_INFORMATION_SERVICE_UUID,
uuid_is,
)
@pytest.mark.parametrize(
"uuid",
[
DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t)
"180A", # 16 bit short form (bt_uuid)
"180a", # lowercase is normalized by bt_uuid but guard anyway
"0000180A", # 32 bit form
"0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form
],
)
def test_uuid_is_matches_all_representations(uuid) -> None:
"""All representations of the same 16 bit UUID must compare equal."""
assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
@pytest.mark.parametrize(
"uuid",
[
0x1818, # Cycling Power Service (different int)
"1818", # different 16 bit short form
"0000180B", # adjacent UUID
"0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix
],
)
def test_uuid_is_rejects_other_uuids(uuid) -> None:
"""A different UUID must not be mistaken for the device information service."""
assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID])
def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
"""Reserved descriptor UUIDs match whether given as int or short string."""
assert uuid_is(uuid16, uuid16)
assert uuid_is(f"{uuid16:04X}", uuid16)
assert uuid_is(f"{uuid16:08X}", uuid16)

View File

@@ -56,3 +56,12 @@ def test_get_esphome_esp_idf_paths_no_override():
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
import esphome.config_validation as cv
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
assert toolchain._get_core_framework_version() == "5.5.4"

View File

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import storage_json
from esphome import config_validation as cv, storage_json
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
from esphome.core import CORE
@@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None:
framework="arduino",
core_platform="esp32",
area="Living Room",
framework_version="5.3.1",
)
result = storage.as_dict()
@@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None:
assert result["framework"] == "arduino"
assert result["core_platform"] == "esp32"
assert result["area"] == "Living Room"
assert result["framework_version"] == "5.3.1"
def test_storage_json_to_json() -> None:
@@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
mock_core.toolchain = Toolchain.ESP_IDF
mock_core.area = "Living Room"
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
with (
patch("esphome.components.esp32.get_esp32_variant") as mock_variant,
patch("esphome.components.esp32.idf_version") as mock_idf_version,
):
mock_variant.return_value = "ESP32-C3"
mock_idf_version.return_value = cv.Version(5, 3, 1)
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
@@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
assert result.core_platform == "esp32"
assert result.toolchain == "esp-idf"
assert result.area == "Living Room"
assert result.framework_version == "5.3.1"
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
@@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain(
assert CORE.toolchain is None
def test_storage_json_framework_version_round_trip(setup_core: Path) -> None:
"""Sidecar framework_version restores CORE.data[esp32][idf_version]."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
storage = _make_storage_with_toolchain("esp-idf")
storage.framework_version = "5.3.1"
path = setup_core / "storage.json"
path.write_text(storage.to_json())
assert json.loads(path.read_text())["framework_version"] == "5.3.1"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.framework_version == "5.3.1"
loaded.apply_to_core()
assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1)
def test_storage_json_apply_to_core_without_framework_version(
setup_core: Path,
) -> None:
"""Older sidecars lacking framework_version don't populate idf_version."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
loaded = _make_storage_with_toolchain("esp-idf")
assert loaded.framework_version is None
loaded.apply_to_core()
assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32]
def test_storage_json_apply_to_core_raises_on_invalid_framework_version(
setup_core: Path,
) -> None:
"""A malformed version string fails with an actionable error at parse time."""
from esphome.core import EsphomeError
loaded = _make_storage_with_toolchain("esp-idf")
loaded.framework_version = "not-a-version"
with pytest.raises(EsphomeError, match="clean the build"):
loaded.apply_to_core()
def test_esphome_storage_json_as_dict() -> None:
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
storage = storage_json.EsphomeStorageJSON(

View File

@@ -76,6 +76,7 @@ def create_storage() -> Callable[..., StorageJSON]:
framework=kwargs.get("framework", "arduino"),
core_platform=kwargs.get("core_platform", "esp32"),
toolchain=kwargs.get("toolchain", "platformio"),
framework_version=kwargs.get("framework_version"),
)
return _create
@@ -121,6 +122,32 @@ def test_storage_should_clean_when_toolchain_changes(
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_framework_changes(
create_storage: Callable[..., StorageJSON],
) -> None:
"""Test that clean is triggered when the framework changes.
Switching between arduino and esp-idf produces incompatible build trees
even on the same toolchain, so the build must be wiped.
"""
old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino")
new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf")
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_framework_version_changes(
create_storage: Callable[..., StorageJSON],
) -> None:
"""Test that clean is triggered when the framework version changes.
A different framework/ESP-IDF version compiles against a different SDK, so
the stale build tree must be wiped.
"""
old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1")
new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0")
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_component_removed(
create_storage: Callable[..., StorageJSON],
) -> None: