Merge remote-tracking branch 'origin/dev' into jesserockz-2026-584

# Conflicts:
#	esphome/components/api/__init__.py
#	platformio.ini
This commit is contained in:
Jesse Hills
2026-08-18 12:00:59 +12:00
65 changed files with 192 additions and 161 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -367,7 +367,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -1095,7 +1095,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.16.3
hooks:
# Run the linter.
- id: ruff
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1
RUN \
platformio settings set enable_telemetry No \
+2 -2
View File
@@ -45,8 +45,8 @@ CODEOWNERS = ["@esphome/core"]
# Keep in sync with platformio.ini and esphome/idf_component.yml.
# LIBSODIUM_VERSION must match the version noise-c pins in its idf_component.yml.
NOISE_C_VERSION = "0.1.18"
LIBSODIUM_VERSION = "1.10021.2"
NOISE_C_VERSION = "0.1.19"
LIBSODIUM_VERSION = "1.10021.3"
def AUTO_LOAD(config: ConfigType) -> list[str]:
+1 -2
View File
@@ -68,12 +68,11 @@ def _unsupported_family_message(family: str) -> str | None:
return None
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
wifi_conf = full_config.get("wifi")
@@ -88,8 +88,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
socket.consume_sockets(3, "captive_portal")(config)
socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -3
View File
@@ -88,7 +88,7 @@ async def to_code(config):
cg.add_library("esphome/dsmr_parser", "1.9.0")
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
for uart_conf in full_config["uart"]:
@@ -102,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType:
)
break
return config
FINAL_VALIDATE_SCHEMA = final_validate
+2 -2
View File
@@ -59,7 +59,7 @@ CONFIG_SCHEMA = (
)
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
# Count sensors registered to this hub (IDs are resolved at final_validate stage)
@@ -95,7 +95,7 @@ def final_validate(config: ConfigType) -> ConfigType:
parity="NONE",
stop_bits=1,
)
return schema(config)
schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
+1 -2
View File
@@ -153,7 +153,7 @@ def customise_schema(config):
CONFIG_SCHEMA = customise_schema
def _final_validate(config):
def _final_validate(config) -> None:
spi.final_validate_device_schema(
"epaper_spi", require_miso=False, require_mosi=True
)(config)
@@ -170,7 +170,6 @@ def _final_validate(config):
config[CONF_SHOW_TEST_CARD] = True
elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -3
View File
@@ -1368,7 +1368,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
return config
def final_validate(config):
def final_validate(config) -> None:
# Imported locally to avoid circular import issues
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
@@ -1629,8 +1629,6 @@ def final_validate(config):
if errs:
raise cv.MultipleInvalid(errs)
return config
CONF_SDKCONFIG_OPTIONS = "sdkconfig_options"
CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server"
+1 -3
View File
@@ -443,7 +443,7 @@ def validate_connection_slots(max_connections: int) -> None:
)
def final_validation(config):
def final_validation(config) -> None:
validate_variant(config)
if (name := config.get(CONF_NAME)) is not None:
full_config = fv.full_config.get()
@@ -514,8 +514,6 @@ def final_validation(config):
# For newer chips (C3/S3/etc), different configs are used automatically
add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections)
return config
FINAL_VALIDATE_SCHEMA = final_validation
@@ -307,7 +307,7 @@ def create_device_information_service(config):
return config
def final_validate_config(config):
def final_validate_config(config) -> None:
# Validate max_clients does not exceed esp32_ble max_connections
max_clients = config[CONF_MAX_CLIENTS]
if max_clients > 1:
@@ -355,7 +355,6 @@ def final_validate_config(config):
raise cv.Invalid(
f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value"
)
return config
def validate_value_type(value_config):
+1 -2
View File
@@ -126,7 +126,7 @@ CONFIG_SCHEMA = cv.typed_schema(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
@@ -136,7 +136,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -767,7 +767,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
raise cv.Invalid(error_msg, path=pin_path)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
if "wifi" in fv.full_config.get():
@@ -787,7 +787,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -60,14 +60,13 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config):
def _final_validate(config) -> None:
if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config:
fconfig = full_config.get()
if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]):
raise cv.Invalid(
"'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -225,7 +225,7 @@ def image_schema(class_: MockObjClass = Image_) -> cv.Schema:
)
def validate_image_final(config: ConfigType) -> ConfigType:
def validate_image_final(config: ConfigType) -> None:
"""Per-entry final validation, shared by file-backed image platforms.
For LVGL 9 the default byte order for RGB565 images is little-endian, so
@@ -240,7 +240,6 @@ def validate_image_final(config: ConfigType) -> ConfigType:
)
else:
config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
return config
async def new_image(config: ConfigType) -> MockObj:
@@ -68,10 +68,10 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool:
return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users)
def _final_validate(config):
def _final_validate(config) -> None:
use_interrupt = config[CONF_USE_INTERRUPT]
if not use_interrupt:
return config
return
# Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt
# attachment — only internal/native GPIO pins do.
@@ -82,7 +82,7 @@ def _final_validate(config):
config.get(CONF_NAME, config[CONF_ID]),
)
config[CONF_USE_INTERRUPT] = False
return config
return
pin_num = config[CONF_PIN][CONF_NUMBER]
@@ -96,7 +96,7 @@ def _final_validate(config):
config.get(CONF_NAME, config[CONF_ID]),
)
config[CONF_USE_INTERRUPT] = False
return config
return
# When a pin is shared, interrupts can interfere with other components
# (e.g., duty_cycle sensor) that need to monitor the pin's state changes.
@@ -120,8 +120,6 @@ def _final_validate(config):
pin_num,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -163,8 +163,8 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("growatt_solar", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("growatt_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -424,7 +424,7 @@ async def power_action_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config):
def _final_validate(config) -> None:
full_config = fv.full_config.get()
if CONF_LOGGER in full_config:
_level = "NONE"
@@ -448,7 +448,6 @@ def _final_validate(config):
raise cv.Invalid(
f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -60,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema(
)
def _final_validate(config):
def _final_validate(config) -> None:
full_config = fv.full_config.get()
for switch_type in [CONF_BEEPER, CONF_QUIET_MODE]:
# Check switches that are only supported for HonClimate
@@ -72,7 +72,6 @@ def _final_validate(config):
raise cv.Invalid(
f"{switch_type} switch is only supported for hon climate"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -217,8 +217,8 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("havells_solar", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("havells_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -3
View File
@@ -315,7 +315,7 @@ def _validate_config(config: ConfigType) -> ConfigType:
return config
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
"""Validate requirements when using HUB75 display."""
# Local imports to avoid circular dependencies
from esphome.components.esp32 import get_esp32_variant
@@ -381,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
if errs:
raise cv.MultipleInvalid(errs)
return config
FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate)
+1 -2
View File
@@ -22,7 +22,7 @@ CONFIG_SCHEMA = (
)
def validate_logger(config):
def validate_logger(config) -> None:
logger_conf = fv.full_config.get()[CONF_LOGGER]
if logger_conf[CONF_BAUD_RATE] == 0:
raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0")
@@ -33,7 +33,6 @@ def validate_logger(config):
raise cv.Invalid(
"improv_serial does not support the selected logger hardware_uart"
)
return config
FINAL_VALIDATE_SCHEMA = validate_logger
+1 -2
View File
@@ -146,13 +146,12 @@ CONFIG_SCHEMA = cv.All(
)
def _validate_cpu_frequency(config):
def _validate_cpu_frequency(config) -> None:
esp32_config = fv.full_config.get()[PLATFORM_ESP32]
if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ":
raise cv.Invalid(
"Inkplate requires 240MHz CPU frequency (set in esp32 component)"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency
+1 -2
View File
@@ -336,7 +336,7 @@ def _customise_schema(config):
CONFIG_SCHEMA = _customise_schema
def _final_validate(config):
def _final_validate(config) -> None:
# IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required.
spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)(
config
@@ -351,7 +351,6 @@ def _final_validate(config):
config[CONF_UPDATE_INTERVAL] = update_interval("never")
else:
config[CONF_SHOW_TEST_CARD] = True
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -89,8 +89,8 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("kuntze", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("kuntze", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -84,7 +84,7 @@ CONFIG_SCHEMA = cv.Schema(
)
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
@@ -108,8 +108,6 @@ def final_validate(config: ConfigType) -> ConfigType:
path=[CONF_WAKE],
)
return config
FINAL_VALIDATE_SCHEMA = final_validate
@@ -105,9 +105,9 @@ CONFIG_SCHEMA = cv.Schema(
)
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config: ConfigType) -> None:
if config.get(CONF_AREA_CONFIG) is None:
return config
return
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
@@ -132,8 +132,6 @@ def final_validate(config: ConfigType) -> ConfigType:
path=[CONF_AREA_CONFIG],
)
return config
FINAL_VALIDATE_SCHEMA = final_validate
+2 -4
View File
@@ -165,7 +165,7 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
"""Validate all recorded effect name references against their target lights.
This runs once per light platform instance. If no light platform is configured,
@@ -173,7 +173,7 @@ def _final_validate(config: ConfigType) -> ConfigType:
"""
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
return config
return
# Drain the lists so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
@@ -217,8 +217,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -34,7 +34,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay"
VOLATILE_CHANNELS = ("A", "B", "C", "D")
def _validate_nonvolatile(config):
def _validate_nonvolatile(config) -> None:
channel = str(config[CONF_CHANNEL])
# Channels E-H address the nonvolatile registers directly — the mirroring options only
@@ -49,7 +49,7 @@ def _validate_nonvolatile(config):
f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the "
f"volatile channels A-D; channels E-H are the nonvolatile registers themselves"
)
return config
return
config.setdefault(CONF_NONVOLATILE, True)
if config[CONF_NONVOLATILE]:
@@ -62,7 +62,6 @@ def _validate_nonvolatile(config):
raise cv.Invalid(
f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'"
)
return config
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
+2 -3
View File
@@ -62,7 +62,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType:
return config
def _require_network_interface(config: ConfigType) -> ConfigType:
def _require_network_interface(config: ConfigType) -> None:
"""Require a network interface for mDNS on Arduino/LEAmDNS platforms.
On ESP8266 and RP2040 the C++ implementation needs at least one IP state
@@ -71,7 +71,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType:
that never initializes.
"""
if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2):
return config
return
full_config = fv.full_config.get()
has_wifi = "wifi" in full_config
has_ethernet = CORE.is_rp2 and "ethernet" in full_config
@@ -81,7 +81,6 @@ def _require_network_interface(config: ConfigType) -> ConfigType:
"mdns on this platform requires a network interface — "
f"add a {options} component to your configuration."
)
return config
CONFIG_SCHEMA = cv.All(
+1 -2
View File
@@ -175,7 +175,7 @@ def _config_schema(config):
return config
def _final_validate(config):
def _final_validate(config) -> None:
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
@@ -183,7 +183,6 @@ def _final_validate(config):
if not requires_buffer(config) and LVGL_DOMAIN not in global_config:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
return config
CONFIG_SCHEMA = _config_schema
+1 -2
View File
@@ -248,7 +248,7 @@ def _config_schema(config):
CONFIG_SCHEMA = _config_schema
def _final_validate(config):
def _final_validate(config) -> None:
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
@@ -260,7 +260,6 @@ def _final_validate(config):
config = spi.final_validate_device_schema(
"mipi_rgb", require_miso=False, require_mosi=True
)(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -143,11 +143,11 @@ def CONFIG_SCHEMA(config: ConfigType) -> ConfigType:
# Legacy climate-owned hub compatibility. Remove in 2027.2.0.
def _legacy_final_validate(config: ConfigType) -> ConfigType:
def _legacy_final_validate(config: ConfigType) -> None:
if CONF_MITSUBISHI_CN105_ID in config:
return config
return
return uart.final_validate_device_schema(
uart.final_validate_device_schema(
DOMAIN,
require_rx=True,
require_tx=True,
@@ -135,10 +135,8 @@ def validate_modbus_register(config):
return config
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("modbus_controller", role="client")(
config
)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("modbus_controller", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -144,8 +144,8 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("modbus_server", role="server")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("modbus_server", role="server")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -44,10 +44,10 @@ CONFIG_SCHEMA = cv.typed_schema(
)
def _final_validate(config):
def _final_validate(config) -> None:
if config[CONF_TYPE] != CONF_STATUS:
# Only run this validation if a status sensor is being configured
return config
return
full_config = fv.full_config.get()
transport_path = full_config.get_path_for_id(config[CONF_TRANSPORT_ID])[:-1]
transport_config = full_config.get_config_for_path(transport_path)
@@ -56,7 +56,7 @@ def _final_validate(config):
for p in transport_config[CONF_PROVIDERS]
if p[CONF_NAME] == config[CONF_PROVIDER]
):
return config
return
raise cv.Invalid(
"Status sensor requires ping-pong to be enabled and the nominated provider to use encryption."
)
+1 -2
View File
@@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
"""Validate the provisioning setup once every component has been processed.
Sources register during their own config validation, so by final validation
@@ -89,7 +89,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
"hardcoding them makes the window pointless.",
", ".join(sorted(data.hardcoded_credentials)),
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -98,8 +98,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemac", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("pzemac", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -80,8 +80,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemdc", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("pzemdc", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# Validate every configured output speaker can accept the router's format.
# Switching to an output that can't reproduce the format the producer is
# already sending would otherwise fail silently at runtime.
@@ -76,7 +76,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
channels=config[CONF_NUM_CHANNELS],
sample_rate=config[CONF_SAMPLE_RATE],
)(proxy)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -71,10 +71,9 @@ def validate_connection_slots() -> None:
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
_validate_board(config)
validate_connection_slots()
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -148,8 +148,8 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("sdm_meter", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("sdm_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config):
def _final_validate(config) -> None:
# In the default mode setup() writes config commands, so tx is required;
# rx_only mode never writes, so tx is optional.
uart.final_validate_device_schema(
@@ -75,7 +75,6 @@ def _final_validate(config):
parity="NONE",
stop_bits=1,
)(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -2
View File
@@ -164,8 +164,8 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("selec_meter", role="client")(config)
def _final_validate(config: ConfigType) -> None:
modbus.final_validate_modbus_device("selec_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config):
def _final_validate(config) -> None:
full_config = fv.full_config.get()
if not any(name in full_config for name in _USB_CLASS_COMPONENTS):
raise cv.Invalid(
@@ -75,7 +75,6 @@ def _final_validate(config):
"USB_SERIAL_JTAG on variants that support it "
"(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -193,7 +193,7 @@ def _validate_no_sorting_component(
)
def _final_validate_sorting(config: ConfigType) -> ConfigType:
def _final_validate_sorting(config: ConfigType) -> None:
if (webserver_version := config.get(CONF_VERSION)) != 3:
_validate_no_sorting_component(
CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get()
@@ -201,7 +201,6 @@ def _final_validate_sorting(config: ConfigType) -> ConfigType:
_validate_no_sorting_component(
CONF_SORTING_GROUP_ID, webserver_version, fv.full_config.get()
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate_sorting
+1 -2
View File
@@ -102,9 +102,8 @@ def _allocate_blocks() -> None:
_get_data().pwm_blocks = pwm_blocks
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
_allocate_blocks()
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -3
View File
@@ -11,7 +11,7 @@ zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy")
ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice)
def final_validate(config):
def final_validate(config) -> None:
full_config = fv.full_config.get()
if (wifi_conf := full_config.get(CONF_WIFI)) and (
wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none"
@@ -20,8 +20,6 @@ def final_validate(config):
f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy"
)
return config
CONFIG_SCHEMA = (
cv.Schema(
+12 -4
View File
@@ -88,9 +88,17 @@ std::string str_sprintf(const char *fmt, ...) {
// --- Base64 helpers ---
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
// Map a 6-bit value (0-63) to its base64 character arithmetically.
// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there).
static inline char base64_char(uint8_t index) {
if (index < 26)
return 'A' + index;
if (index < 52)
return 'a' + (index - 26);
if (index < 62)
return '0' + (index - 52);
return index == 62 ? '+' : '/';
}
// Encode 3 input bytes to 4 base64 characters, append 'count' to ret.
static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) {
@@ -101,7 +109,7 @@ static inline void base64_encode_triple(const char *char_array_3, int count, std
char_array_4[3] = char_array_3[2] & 0x3f;
for (int j = 0; j < count; j++)
ret += BASE64_CHARS[static_cast<uint8_t>(char_array_4[j])];
ret += base64_char(static_cast<uint8_t>(char_array_4[j]));
}
std::string base64_encode(const std::vector<uint8_t> &buf) { return base64_encode(buf.data(), buf.size()); }
+12 -12
View File
@@ -579,13 +579,8 @@ int8_t step_to_accuracy_decimals(float step) {
return str.length() - dot_pos - 1;
}
// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes)
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
// Helper function to find the index of a base64/base64url character in the lookup table.
// Returns the character's position (0-63) if found, or 0 if not found.
// Map a base64/base64url character to its 6-bit value (0-63) arithmetically.
// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there).
// Supports both standard base64 (+/) and base64url (-_) alphabets.
// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters.
// This is safe because is_base64() is ALWAYS checked before calling this function,
@@ -593,13 +588,18 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// stops processing at the first invalid character due to the is_base64() check in its
// while loop condition, making this edge case harmless in practice.
static inline uint8_t base64_find_char(char c) {
// Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63)
if (c == '-')
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
// base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63)
if (c == '+' || c == '-')
return 62;
if (c == '_')
if (c == '/' || c == '_')
return 63;
const char *pos = strchr(BASE64_CHARS, c);
return pos ? (pos - BASE64_CHARS) : 0;
return 0;
}
// Check if character is valid base64 or base64url
+2 -2
View File
@@ -110,12 +110,12 @@ dependencies:
# refuses to build two managed components whose names differ only by
# namespace. The Arduino envs get noise-c as a PlatformIO library instead.
esphome/noise-c:
version: 0.1.18
version: 0.1.19
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
# Declared even though noise-c depends on it, so that the PlatformIO-library
# converter knows to skip the copy esp_wireguard would otherwise pull in.
esphome/libsodium:
version: 1.10021.2
version: 1.10021.3
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
+3 -3
View File
@@ -78,7 +78,7 @@ lib_deps =
${common.lib_deps}
; api -- on the ESP-IDF framework this comes from the component registry
; instead (see esphome/idf_component.yml), so it is not in [common].
esphome/noise-c@0.1.18 ; api
esphome/noise-c@0.1.19 ; api
SPI ; spi (Arduino built-in)
Wire ; i2c (Arduino built-int)
heman/AsyncMqttClient-esphome@1.0.0 ; mqtt
@@ -246,7 +246,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.18 ; api
esphome/noise-c@0.1.19 ; api
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -643,7 +643,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.18 ; used by api
esphome/noise-c@0.1.19 ; used by api
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+2 -2
View File
@@ -27,8 +27,8 @@ bleak==3.0.2
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.7
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.2 # also change in .pre-commit-config.yaml when updating
ruff==0.16.3 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.4.13 # also change in .github/workflows/ci.yml when updating
@@ -19,7 +19,7 @@ def test_final_validate_accepts_supported_idf(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
assert _final_validate({}) == {}
_final_validate({})
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
+9 -8
View File
@@ -371,27 +371,28 @@ def test_migrate_returns_none_for_invalid_legacy_shapes(
def test_validate_image_final_defaults_to_little_endian() -> None:
out = validate_image_final({CONF_FILE: "x.png"})
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
config = {CONF_FILE: "x.png"}
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
def test_validate_image_final_keeps_little_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final(
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
)
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
assert "big-endian" not in caplog.text
def test_validate_image_final_warns_on_big_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN"
assert "big-endian" in caplog.text
@@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source(
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
# Should not raise.
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
def test_provisioning_warns_on_hardcoded_credentials(
@@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials(
register_source("network")
report_hardcoded_credentials("wifi")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
assert "wifi" in caplog.text
assert "credentials" in caplog.text
@@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
FINAL_VALIDATE_SCHEMA({})
assert "credentials" not in caplog.text
+67
View File
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
@@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) {
EXPECT_EQ(end - buf, 7);
}
// --- base64 encode/decode ---
static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order
TEST(Base64, EncodeProducesCanonicalAlphabet) {
uint8_t bytes[48];
size_t n = 0;
for (uint8_t i = 0; i < 64; i += 4) {
bytes[n++] = (i << 2) | ((i + 1) >> 4);
bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2);
bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3);
}
std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, BASE64_ALPHABET);
}
// Decode the alphabet then re-encode: locks the encode and decode mappings together
TEST(Base64, DecodeCanonicalAlphabetRoundTrip) {
uint8_t buf[48];
size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf));
EXPECT_EQ(len, 48u);
std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(reencoded, BASE64_ALPHABET);
}
TEST(Base64, DecodeBase64UrlMatchesStandard) {
std::string url = BASE64_ALPHABET;
for (char &c : url) {
if (c == '+')
c = '-';
if (c == '/')
c = '_';
}
uint8_t standard[48], urlsafe[48];
size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard));
size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe));
EXPECT_EQ(len_standard, len_url);
EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0);
}
// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2)
TEST(Base64, Rfc4648Vectors) {
const struct {
const char *plain;
const char *encoded;
} vectors[] = {
{"", ""},
{"f", "Zg=="},
{"fo", "Zm8="},
{"foo", "Zm9v"},
{"foob", "Zm9vYg=="},
{"fooba", "Zm9vYmE="},
{"foobar", "Zm9vYmFy"},
};
for (const auto &v : vectors) {
const auto *plain = reinterpret_cast<const uint8_t *>(v.plain);
std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, v.encoded);
uint8_t buf[8];
size_t len = base64_decode(reinterpret_cast<const uint8_t *>(v.encoded), strlen(v.encoded), buf, sizeof(buf));
EXPECT_EQ(len, strlen(v.plain));
EXPECT_EQ(memcmp(buf, v.plain, len), 0);
}
}
} // namespace esphome::core::testing