Merge branch 'esp8266-native-toolchain-plumbing' into esp8266-native-build-infra

This commit is contained in:
J. Nick Koston
2026-08-25 10:47:58 -05:00
70 changed files with 1706 additions and 205 deletions
+1
View File
@@ -476,6 +476,7 @@ esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sha256/* @esphome/core
+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.12.4
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.0
RUN \
platformio settings set enable_telemetry No \
+3
View File
@@ -7,6 +7,7 @@ from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
require_certificate_bundle,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -335,6 +336,8 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(
name="esphome/esp-audio-libs",
+101 -43
View File
@@ -65,6 +65,7 @@ from .boards import BOARDS, STANDARD_BOARDS
from .const import (
KEY_ARDUINO_LIBRARIES,
KEY_BOARD,
KEY_CERT_BUNDLE,
KEY_COMPONENTS,
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
@@ -237,6 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component
"esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server
"esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation
"esp_https_server", # HTTPS server - ESPHome has its own web server
"esp_lcd", # LCD controller drivers - only needed by display component
@@ -245,6 +247,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"json", # cJSON library - ESPHome uses ArduinoJson instead
"mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation
"nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set
"openthread", # Thread protocol - only needed by openthread component
"perfmon", # Xtensa performance monitor - ESPHome has its own debug component
"protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded)
@@ -343,6 +346,10 @@ ARDUINO_LIBRARY_IDF_COMPONENTS: dict[str, tuple[str, ...]] = {
"Zigbee": ("espressif__esp-zigbee-lib", "espressif__esp-zboss-lib"),
}
# Arduino libraries whose sources reference esp_crt_bundle_attach without a
# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE guard, so enabling them needs the bundle.
ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE = frozenset({"NetworkClientSecure"})
# Arduino library to Arduino library dependencies
# When enabling one library, also enable its dependencies
# Kconfig "select" statements don't work with CONFIG_ARDUINO_SELECTIVE_COMPILATION
@@ -644,6 +651,27 @@ class RawSdkconfigValue:
SdkconfigValueType = bool | int | HexInt | str | RawSdkconfigValue
def is_idf_sdkconfig_option_enabled(name: str) -> bool:
"""Return True when a bool sdkconfig option resolves to ``y``.
Handles both the ``True`` a component sets and the raw ``y`` a user sets
in ``sdkconfig_options``.
"""
value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
return value is not None and _format_sdkconfig_val(value) == "y"
def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None:
"""Set an sdkconfig option unless it is already set.
For the FINAL priority reconcile jobs: they run after every to_code,
including the user's sdkconfig_options, and must not override an
existing value.
"""
if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
add_idf_sdkconfig_option(name, value)
def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType):
"""Set an esp-idf sdkconfig value."""
CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value
@@ -788,6 +816,10 @@ def _enable_arduino_library(name: str) -> None:
# Also enable any required IDF components
for idf_component in ARDUINO_LIBRARY_IDF_COMPONENTS.get(name, ()):
include_builtin_idf_component(idf_component)
if not ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE.isdisjoint(
{name, *ARDUINO_LIBRARY_DEPENDENCIES.get(name, ())}
):
require_certificate_bundle()
def add_extra_script(stage: str, filename: str, path: Path):
@@ -1727,6 +1759,16 @@ def require_vfs_termios() -> None:
CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True
def require_certificate_bundle() -> None:
"""Enable the mbedTLS root certificate bundle for this build.
The bundle is off by default; components that verify TLS server
certificates (http_request, audio streaming) call this so the bundle is
compiled and gen_crt_bundle runs only when something uses it.
"""
CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True
def require_full_certificate_bundle() -> None:
"""Request the full certificate bundle instead of the common-CAs-only bundle.
@@ -1736,6 +1778,7 @@ def require_full_certificate_bundle() -> None:
Call this from components that need to connect to services using uncommon CAs.
"""
require_certificate_bundle()
CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True
@@ -2152,6 +2195,10 @@ def register_exclude_components_cmake_arg() -> None:
@coroutine_with_priority(CoroPriority.FINAL)
async def _write_exclude_components() -> None:
"""Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions."""
# NVS encryption needs nvs_sec_provider however it was enabled: the
# nvs_encryption option, raw sdkconfig_options or another component.
if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"):
include_builtin_idf_component("nvs_sec_provider")
register_exclude_components_cmake_arg()
@@ -2210,6 +2257,31 @@ async def _set_libc_picolibc_newlib_compat() -> None:
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_certificate_bundle_sdkconfig() -> None:
"""Enable the mbedTLS certificate bundle only when something asked for it.
Runs at FINAL priority so every require_certificate_bundle() call has
happened. Without a request the bundle is disabled, which skips
esp_crt_bundle.c, the gen_crt_bundle step and the x509_crt_bundle.S embed.
A user-supplied sdkconfig_options value takes precedence.
"""
data = CORE.data[KEY_ESP32]
enabled = data.get(KEY_CERT_BUNDLE, False)
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", enabled)
if not enabled:
return
# Use CMN (common CAs) bundle by default to save ~51KB flash
# CMN covers CAs with >1% market share (~99% of websites)
# Components needing uncommon CAs can call require_full_certificate_bundle()
use_full_bundle = data.get(KEY_FULL_CERT_BUNDLE, False)
set_idf_sdkconfig_default(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle
)
if not use_full_bundle:
set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_network_sdkconfig() -> None:
"""Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags.
@@ -2221,37 +2293,31 @@ async def _reconcile_network_sdkconfig() -> None:
always takes precedence.
"""
net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData())
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
is_arduino = CORE.using_arduino
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# Bluetooth: only ever enable when requested. The IDF default is off.
# According to the IDF docs, only one of 4.2 or 5.0 should be enabled.
if net.bluetooth:
set_opt("CONFIG_BT_ENABLED", True)
set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_opt("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
set_idf_sdkconfig_default("CONFIG_BT_ENABLED", True)
set_idf_sdkconfig_default("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True)
set_idf_sdkconfig_default("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False)
# WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi
# relies on the IDF default (enabled), so it is never written True here.
wifi_disabled = net.ethernet and not net.wifi
if wifi_disabled:
set_opt("CONFIG_ESP_WIFI_ENABLED", False)
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENABLED", False)
# Software coexistence: enable when requested (the schema only allows it
# alongside WiFi). Disable only in the Ethernet-without-WiFi case.
if net.software_coexistence:
set_opt("CONFIG_SW_COEXIST_ENABLE", True)
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True)
elif wifi_disabled:
set_opt("CONFIG_SW_COEXIST_ENABLE", False)
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", False)
# SoftAP support: drop it when WiFi is used without AP mode (IDF only).
if not is_arduino and net.wifi and not net.wifi_ap:
set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False)
# LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not
# coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server
@@ -2262,7 +2328,7 @@ async def _reconcile_network_sdkconfig() -> None:
if (
wifi_wants_dhcps_off or dhcp_server_disabled_by_option
) and not arduino_eth_exclusion:
set_opt("CONFIG_LWIP_DHCPS", False)
set_idf_sdkconfig_default("CONFIG_LWIP_DHCPS", False)
@coroutine_with_priority(CoroPriority.FINAL)
@@ -2287,29 +2353,24 @@ async def _reconcile_vfs_fatfs_sdkconfig(
"""Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win."""
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off.
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
# VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread);
# sockets use lwip_select() either way. ~2.7KB flash when off.
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_SELECT", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", True)
else:
set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
# Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off.
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_DIR", True)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", True)
else:
set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
# FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only;
# sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set
@@ -2322,15 +2383,15 @@ async def _reconcile_vfs_fatfs_sdkconfig(
user_picked_lfn = any(k in opts for k in lfn_keys)
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", False)
set_opt("CONFIG_FATFS_LFN_HEAP", True)
set_opt("CONFIG_FATFS_MAX_LFN", 255)
set_opt("CONFIG_FATFS_VOLUME_COUNT", 4)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", False)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_HEAP", True)
set_idf_sdkconfig_default("CONFIG_FATFS_MAX_LFN", 255)
set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 4)
elif disable_fatfs:
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", True)
set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
set_opt("CONFIG_FATFS_VOLUME_COUNT", 1)
set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 1)
@coroutine_with_priority(CoroPriority.FINAL - 1)
@@ -2517,21 +2578,11 @@ async def to_code(config):
)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
cg.add_build_flag("-Wno-nonnull-compare")
# Use CMN (common CAs) bundle by default to save ~51KB flash
# CMN covers CAs with >1% market share (~99% of websites)
# Components needing uncommon CAs can call require_full_certificate_bundle()
use_full_bundle = conf[CONF_ADVANCED].get(
CONF_USE_FULL_CERTIFICATE_BUNDLE, False
) or CORE.data[KEY_ESP32].get(KEY_FULL_CERT_BUNDLE, False)
add_idf_sdkconfig_option(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle
)
if not use_full_bundle:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True)
if conf[CONF_ADVANCED].get(CONF_USE_FULL_CERTIFICATE_BUNDLE, False):
require_full_certificate_bundle()
add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True)
add_idf_sdkconfig_option(
@@ -2921,6 +2972,9 @@ async def to_code(config):
# FINAL priority: runs after every network/coexistence request_*() call
CORE.add_job(_reconcile_network_sdkconfig)
# FINAL priority: runs after every require_certificate_bundle() call
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
@@ -2948,6 +3002,10 @@ async def to_code(config):
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
# A bundle forced on through sdkconfig_options is a request like any other,
# so it still gets the CMN variant pinned.
if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y":
require_certificate_bundle()
# Components from YAML are added in a separate coroutine with FINAL priority
# Schedule it to run after all other components
+1
View File
@@ -27,6 +27,7 @@ KEY_REFRESH = "refresh"
KEY_PATH = "path"
KEY_SUBMODULES = "submodules"
KEY_EXTRA_BUILD_FILES = "extra_build_files"
KEY_CERT_BUNDLE = "cert_bundle"
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PORT
from esphome.types import ConfigType
@@ -35,6 +36,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_MODE): cv.enum(MODES, upper=True),
},
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_consume_camera_web_server_sockets,
)
@@ -44,3 +46,5 @@ async def to_code(config: ConfigType) -> None:
cg.add(server.set_port(config[CONF_PORT]))
cg.add(server.set_mode(config[CONF_MODE]))
await cg.register_component(server, config)
# esp_http_server is excluded from IDF builds by default to save compile time
include_builtin_idf_component("esp_http_server")
+76 -7
View File
@@ -4,6 +4,7 @@ import logging
from esphome import automation, pins
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.network import (
add_use_address,
get_network_priority,
@@ -39,6 +40,7 @@ from esphome.const import (
CONF_POLLING_INTERVAL,
CONF_RESET_PIN,
CONF_SPI,
CONF_SPI_ID,
CONF_STATIC_IP,
CONF_SUBNET,
CONF_TYPE,
@@ -263,10 +265,42 @@ def _is_framework_spi_polling_mode_supported() -> bool:
return False
# Options that come from the referenced spi bus when spi_id is set
_SPI_BUS_PROVIDED_OPTIONS = (
CONF_CLK_PIN,
CONF_MOSI_PIN,
CONF_MISO_PIN,
CONF_INTERFACE,
)
def _validate_spi_bus(config: ConfigType) -> ConfigType:
"""Cross-validate spi_id against the options the referenced bus provides."""
if CONF_SPI_ID in config:
for key in _SPI_BUS_PROVIDED_OPTIONS:
if key in config:
raise cv.Invalid(
f"'{key}' cannot be used together with '{CONF_SPI_ID}'; "
f"it comes from the referenced 'spi:' bus.",
path=[key],
)
else:
for key in (CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN):
if key not in config:
raise cv.Invalid(
f"'{key}' is a required option when '{CONF_SPI_ID}' is not set.",
path=[key],
)
return config
def _validate_spi_interface(config: ConfigType) -> ConfigType:
"""Set default SPI interface or validate user choice against the variant."""
if not CORE.is_esp32:
return config
if CONF_SPI_ID in config:
# The interface comes from the referenced spi bus; don't set a default.
return config
from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant
from esphome.components.spi import get_hw_interface_list
@@ -451,9 +485,14 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) ->
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
# clk/mosi/miso are required unless spi_id is set; enforced
# by _validate_spi_bus below.
cv.Optional(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_SPI_ID): cv.All(
cv.only_on_esp32, cv.use_id(spi.SPIComponent)
),
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(
CONF_INTERRUPT_PIN
@@ -478,6 +517,7 @@ def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) ->
),
),
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_bus,
_validate_spi_interface,
)
@@ -529,6 +569,30 @@ def _final_validate_spi(config: ConfigType) -> None:
return
from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface
if CONF_SPI_ID in config:
# Sharing the bus: the standard spi device schema enforces that the
# referenced bus declares both data lines. The IDF ethernet drivers
# additionally need a hardware host, which shows as an interface index
# on the validated bus config.
spi.final_validate_device_schema(
"ethernet", require_mosi=True, require_miso=True
)(config)
cv.Schema(
{
cv.Required(CONF_SPI_ID): fv.id_declaration_match_schema(
{
cv.Required(
CONF_INTERFACE_INDEX,
msg="Component ethernet requires this spi bus to use "
"a hardware interface",
): cv.valid
}
)
},
extra=cv.ALLOW_EXTRA,
)(config)
return
if spi_configs := fv.full_config.get().get(CONF_SPI):
# get_spi_interface() returns strings like "SPI2_HOST"
spi_host = f"{config[CONF_INTERFACE].upper()}_HOST"
@@ -625,9 +689,15 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
)
if config[CONF_TYPE] in SPI_ETHERNET_TYPES:
cg.add(var.set_clk_pin(config[CONF_CLK_PIN]))
cg.add(var.set_miso_pin(config[CONF_MISO_PIN]))
cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN]))
if (spi_id := config.get(CONF_SPI_ID)) is not None:
# Pins and host come from the shared spi bus.
spi_parent = await cg.get_variable(spi_id)
cg.add(var.set_spi_parent(spi_parent))
else:
cg.add(var.set_clk_pin(config[CONF_CLK_PIN]))
cg.add(var.set_miso_pin(config[CONF_MISO_PIN]))
cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN]))
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
cg.add(var.set_cs_pin(config[CONF_CS_PIN]))
if CONF_INTERRUPT_PIN in config:
cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN]))
@@ -641,7 +711,6 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
cg.add_define("USE_ETHERNET_SPI")
cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]]))
add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True)
# CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0
# Types that are never built into IDF ship no Kconfig option at all
@@ -13,6 +13,9 @@
#include "esp_eth.h"
#ifdef USE_ETHERNET_SPI
#include "hal/spi_types.h"
#ifdef USE_SPI
#include "esphome/components/spi/spi.h"
#endif
#endif
#include "esp_eth_mac.h"
#include "esp_eth_mac_esp.h"
@@ -176,6 +179,9 @@ class EthernetComponent final : public Component {
void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; }
void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; }
void set_interface(spi_host_device_t interface) { this->interface_ = interface; }
#ifdef USE_SPI
void set_spi_parent(spi::SPIComponent *parent) { this->spi_parent_ = parent; }
#endif
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; }
#endif
@@ -258,6 +264,11 @@ class EthernetComponent final : public Component {
int phy_addr_spi_{-1};
int clock_speed_;
spi_host_device_t interface_{SPI2_HOST};
#ifdef USE_SPI
// When set, the SPI bus is owned and initialized by this spi component
// and the ethernet chip only adds a device to it.
spi::SPIComponent *spi_parent_{nullptr};
#endif
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
uint32_t polling_interval_{0};
#endif
@@ -59,6 +59,9 @@
#ifdef USE_ETHERNET_SPI
#include <driver/gpio.h>
#include <driver/spi_master.h>
#ifdef USE_SPI
#include "esphome/components/spi/spi.h"
#endif
#endif
namespace esphome::ethernet {
@@ -168,25 +171,34 @@ void EthernetComponent::ethernet_lazy_init_() {
// Install GPIO ISR handler to be able to service SPI Eth modules interrupts
gpio_install_isr_service(0);
spi_bus_config_t buscfg = {
.mosi_io_num = this->mosi_pin_,
.miso_io_num = this->miso_pin_,
.sclk_io_num = this->clk_pin_,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.data4_io_num = -1,
.data5_io_num = -1,
.data6_io_num = -1,
.data7_io_num = -1,
.max_transfer_sz = 0,
.flags = 0,
.intr_flags = 0,
};
spi_host_device_t host;
#ifdef USE_SPI
if (this->spi_parent_ != nullptr) {
// The bus is owned and already initialized by the spi component; share its host.
host = this->spi_parent_->get_interface();
} else
#endif
{
spi_bus_config_t buscfg = {
.mosi_io_num = this->mosi_pin_,
.miso_io_num = this->miso_pin_,
.sclk_io_num = this->clk_pin_,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.data4_io_num = -1,
.data5_io_num = -1,
.data6_io_num = -1,
.data7_io_num = -1,
.max_transfer_sz = 0,
.flags = 0,
.intr_flags = 0,
};
auto host = this->interface_;
host = this->interface_;
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
}
#endif
// Network interface setup handled by network component
@@ -575,17 +587,25 @@ void EthernetComponent::dump_config() {
YESNO(this->is_connected()));
this->dump_connect_params_();
#ifdef USE_ETHERNET_SPI
ESP_LOGCONFIG(TAG,
" CLK Pin: %u\n"
" MISO Pin: %u\n"
" MOSI Pin: %u\n"
" CS Pin: %u",
this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_);
const char *spi_interface = "spi3";
if (this->interface_ == SPI2_HOST) {
spi_interface = "spi2";
#ifdef USE_SPI
if (this->spi_parent_ != nullptr) {
// Pins and interface come from the shared spi bus; only CS is ours.
ESP_LOGCONFIG(TAG, " CS Pin: %u", this->cs_pin_);
} else
#endif
{
ESP_LOGCONFIG(TAG,
" CLK Pin: %u\n"
" MISO Pin: %u\n"
" MOSI Pin: %u\n"
" CS Pin: %u",
this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_);
const char *spi_interface = "spi3";
if (this->interface_ == SPI2_HOST) {
spi_interface = "spi2";
}
ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface);
}
ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface);
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
if (this->polling_interval_ != 0) {
ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_);
+1 -3
View File
@@ -196,9 +196,7 @@ async def to_code(config: ConfigType) -> None:
# framework:
# advanced:
# use_full_certificate_bundle: true
esp32.add_idf_sdkconfig_option(
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True
)
esp32.require_certificate_bundle()
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_TLS_INSECURE",
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::output {
static const char *const TAG = "output.automation";
} // namespace esphome::output
+105 -5
View File
@@ -4,14 +4,15 @@ Scan modes:
continuous: true — scan runs forever; never stops automatically.
continuous: false — a started scan runs for `duration`, then stops. The first
start is external too; nothing starts a non-continuous
scan on boot. Until start/stop automation actions land
(follow-up PR), starting means a lambda:
`id(my_tracker).start_scan();`.
scan on boot — use the rp2_ble_tracker.start_scan action
(e.g. from api: on_client_connected:).
"""
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, ota, rp2040_ble
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.ble_device_base import automation as ble_automation
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID
import esphome.config_validation as cv
from esphome.const import (
@@ -20,7 +21,13 @@ from esphome.const import (
CONF_DURATION,
CONF_ID,
CONF_INTERVAL,
CONF_MANUFACTURER_ID,
CONF_ON_BLE_ADVERTISE,
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
CONF_SERVICE_UUID,
)
from esphome.core import ID
from esphome.types import ConfigType
DEPENDENCIES = ["rp2"]
@@ -34,6 +41,14 @@ RP2BLETracker = rp2_ble_tracker_ns.class_(
"RP2BLETracker", ble_device_base.BLEHub, cg.Component
)
StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action)
StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action)
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
# interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle —
# the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for
@@ -48,6 +63,24 @@ CONFIG_SCHEMA = cv.Schema(
cv.GenerateID(): cv.declare_id(RP2BLETracker),
cv.GenerateID(CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
ESPBTAdvertiseTrigger
),
cv.Optional(
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEServiceDataAdvertiseTrigger,
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
),
cv.Optional(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEManufacturerDataAdvertiseTrigger,
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
),
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
BLEEndOfScanTrigger
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -76,4 +109,71 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
await ble_automation.advertise_trigger_to_code(conf, var)
for trigger_key, uuid_key, setter_prefix in (
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_MANUFACTURER_ID,
"set_manufacturer_uuid",
),
):
for conf in config.get(trigger_key, []):
await ble_automation.uuid_trigger_to_code(
conf, var, uuid_key, setter_prefix
)
for conf in config.get(CONF_ON_SCAN_END, []):
await ble_automation.scan_end_trigger_to_code(conf, var)
@automation.register_action(
"rp2_ble_tracker.start_scan",
StartScanAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(RP2BLETracker),
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
}
),
synchronous=True,
)
async def start_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
template_ = await cg.templatable(continuous, args, cg.bool_)
cg.add(var.set_continuous(template_))
return var
@automation.register_action(
"rp2_ble_tracker.stop_scan",
StopScanAction,
automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(RP2BLETracker),
}
)
),
synchronous=True,
)
async def stop_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -0,0 +1,47 @@
// Scan-control actions for rp2_ble_tracker. The automation triggers are the
// neutral ble_device_base classes (ble_device_base/automation.h).
#pragma once
#ifdef USE_RP2
#include "rp2_ble_tracker.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome::rp2_ble_tracker {
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
public:
TEMPLATABLE_VALUE(bool, continuous)
void play(const Ts &...x) override {
// With continuous: set, the action wins. Without it, the configured value
// is used - stop_scan() clears the runtime flag permanently, so a bare
// stop_scan/start_scan pair would otherwise never resume continuous mode.
const bool want =
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
if (this->parent_->scan_running()) {
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
// the duration window here would let a repeated action keep a one-shot
// scan alive forever. A real mode switch re-anchors so a change to
// one-shot runs a full duration from now.
if (want != this->parent_->scan_continuous()) {
this->parent_->set_scan_continuous(want);
this->parent_->restart_scan_duration();
}
return;
}
this->parent_->set_scan_continuous(want);
this->parent_->start_scan();
}
};
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
public:
void play(const Ts &...x) override { this->parent_->stop_scan(); }
};
} // namespace esphome::rp2_ble_tracker
#endif // USE_RP2
@@ -11,10 +11,8 @@ namespace esphome::rp2_ble_tracker {
static const char *const TAG = "rp2_ble_tracker";
// Minimum interval between scan start attempts on an active stack. The
// controller start has no failure mode once HCI is WORKING, so this fires at
// most once per enable cycle today; the floor is insurance against a future
// scan_start() failure being retried every main-loop iteration.
// Floor between controller start attempts; insurance against a failing
// scan_start() being retried every loop.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
// One BLE scan unit in milliseconds; the controller programs interval/window in these units.
@@ -32,7 +30,8 @@ void RP2BLETracker::setup() {
// the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
if (!this->scan_continuous_) {
// An on_boot start_scan runs before setup(); parking here would strand it.
if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) {
// Nothing to do until an external start_scan(); the loop is re-enabled there.
this->disable_loop();
}
@@ -41,12 +40,21 @@ void RP2BLETracker::setup() {
#ifdef USE_OTA_STATE_LISTENER
void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
// Set before stop_scan(): its on_scan_end automations run synchronously and
// may call start_scan(), which must defer instead of resuming the radio.
this->ota_in_progress_ = true;
this->scan_continuous_before_ota_ = this->scan_continuous_;
// A one-shot scan counts as pending when it is running or still retrying
// its start (loop enabled); captured before stop_scan() disables the loop.
this->scan_pending_before_ota_ = !this->scan_continuous_ && (this->scan_running_ || this->is_in_loop_state());
// A one-shot scan counts as pending when it is running, latched, or still
// retrying its start (loop enabled); captured before stop_scan() parks it.
this->scan_pending_before_ota_ =
!this->scan_continuous_ && (this->scan_running_ || this->pending_start_ || this->is_in_loop_state());
// The pause's own stop is not a user stop, so it must not clear the latches
// captured just above.
this->ota_pausing_ = true;
this->stop_scan();
this->ota_pausing_ = false;
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
this->ota_in_progress_ = false;
// On success the device reboots, so restore only on a failed/aborted update;
// loop()'s retry branch restarts the scan on its next iteration.
if (this->scan_continuous_before_ota_) {
@@ -54,9 +62,7 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin
this->scan_continuous_ = true;
this->enable_loop();
}
// A one-shot scan interrupted by the OTA resumes for a fresh duration
// rather than silently staying idle — an OTA failure does not reboot, so
// nothing external would restart it.
// A failed OTA does not reboot, so nothing else would restart a one-shot.
if (this->scan_pending_before_ota_) {
this->scan_pending_before_ota_ = false;
this->enable_loop();
@@ -66,27 +72,34 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin
#endif // USE_OTA_STATE_LISTENER
void RP2BLETracker::loop() {
#ifdef USE_OTA_STATE_LISTENER
// Keeps "no radio during an OTA" local instead of emergent from the
// parking sites.
if (this->ota_in_progress_)
return;
#endif
const uint32_t now = App.get_loop_component_start_time();
if (this->pending_start_ && this->parent_->is_active()) {
// Latched start, applied once the stack is ACTIVE; earlier attempts would
// fail and arm the retry floor for nothing.
this->pending_start_ = false;
if (!this->scan_running_)
this->start_scan_();
}
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout.
if (!this->merger_.empty())
this->merger_.sweep(now);
if (this->scan_running_ && !this->parent_->is_active()) {
// The controller was disabled underneath us (e.g. a lambda calling
// rp2040_ble's disable()); the scan died with the stack. Reconcile so the
// retry branch below takes over once the user re-enables the stack.
// Stack disabled underneath us; reconcile so the retry branch takes over.
this->scan_running_ = false;
this->fire_scan_end_();
}
if (!this->scan_running_) {
// A scan should be running but is not: continuous mode is always in this
// state until the start succeeds, and non-continuous mode only reaches
// here between start_scan() and a successful controller start, because
// stop_scan_() disables the loop otherwise.
// Should be scanning but is not: continuous until the start succeeds,
// one-shot only between start_scan() and a successful controller start.
if (!this->parent_->is_active()) {
// Stack not up (still booting, or the user called disable()) —
// scan_start() cannot succeed, so there is nothing to attempt; scanning
// starts on the first iteration after HCI reaches WORKING.
// Stack not up: scan_start() cannot succeed yet.
return;
}
if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) {
@@ -107,7 +120,7 @@ void RP2BLETracker::loop() {
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. api: on_client_connected:).
if (now - this->scan_period_start_ >= this->scan_duration_) {
if (now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
@@ -126,24 +139,20 @@ void RP2BLETracker::dump_config() {
YESNO(this->scan_continuous_));
}
// GAP advertising event types as BTstack reports them (Core spec advertising
// report event types; the tracker deliberately does not include BTstack
// headers). ADV_IND and ADV_SCAN_IND are the scannable types.
// Core spec advertising report event types (BTstack headers stay out of this
// TU). ADV_IND and ADV_SCAN_IND are the scannable ones.
static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0;
static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2;
static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4;
// Demux advertisements vs scan responses into the shared merger: BTstack
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
// BTstack delivers the pair as separate reports; the merger holds a scannable
// advertisement until its response arrives.
void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) {
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
return;
}
// Stash only while an active scan runs: a passive scan never gets a
// response, and after a stop nothing would sweep the merger, so a late
// report would surface minutes later as a fresh advertisement.
// Only while an active scan runs: nothing sweeps the merger after a stop.
if (this->scan_running_ && this->scan_active_ &&
(report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) {
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
@@ -157,20 +166,49 @@ void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
void RP2BLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
#ifdef USE_OTA_STATE_LISTENER
if (this->ota_in_progress_) {
// Defer to the post-OTA resume path, carrying the requested mode. Not
// while ota_pausing_: scan_continuous_ is an artefact of the pause's own
// stop there, not intent.
if (!this->ota_pausing_) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_pending_before_ota_ = !this->scan_continuous_;
}
return;
}
#endif
this->enable_loop();
if (!this->is_ready() || !this->parent_->is_active()) {
// Pre-setup or stack not ACTIVE: latch, loop() applies it.
this->pending_start_ = true;
return;
}
// bk72xx force semantics: a user start jumps the floor only while the
// controller is healthy. loop()'s retry branch picks the request up.
if (this->last_start_failed_ &&
App.get_loop_component_start_time() - this->last_scan_start_attempt_ < SCAN_START_RETRY_MS) {
return;
}
this->start_scan_();
}
void RP2BLETracker::restart_scan_duration() {
if (!this->scan_running_)
return; // start_scan_() anchors the clock itself on the next real start
// One-shot clock only (bk72xx parity); re-anchoring the period would let
// repeated actions starve on_scan_end. Same clock as loop()'s now.
this->scan_start_time_ = App.get_loop_component_start_time();
}
bool RP2BLETracker::request_scan_mode(bool active) {
if (this->scan_active_ == active)
return true;
this->scan_active_ = active;
// V: the proxy's "Setting scanner mode" line already narrates this at D.
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
// Apply to a running scan by restarting the CONTROLLER scan with the new
// mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the
// scan logically continues, only the request mode changes), no period reset.
// An idle scanner picks the mode up on its next start.
// Restart the controller scan only: the scan logically continues, so no
// on_scan_end and no period reset. An idle scanner applies it on next start.
if (this->scan_running_) {
this->parent_->scan_stop();
if (!this->controller_scan_start_()) {
@@ -184,20 +222,34 @@ bool RP2BLETracker::request_scan_mode(bool active) {
}
void RP2BLETracker::stop_scan() {
// Cancel a start latched before setup(); without this an on_boot
// start_scan/stop_scan pair would still start at the first loop().
this->pending_start_ = false;
this->scan_continuous_ = false;
#ifdef USE_OTA_STATE_LISTENER
// A user stop during the OTA is the latest intent; the pause's own stop
// (ota_pausing_) is exempt - it armed that state.
if (this->ota_in_progress_ && !this->ota_pausing_) {
this->scan_pending_before_ota_ = false;
this->scan_continuous_before_ota_ = false;
}
#endif
this->stop_scan_();
// stop_scan_() early-returns when no scan is running, so disable the loop
// here too: a scan that never came up (stack still powering on at OTA start)
// must not keep attempting scan_start() from the loop's retry branch.
this->disable_loop();
// stop_scan_() early-returns when idle, so park here too - once set up, and
// re-checked: its synchronous on_scan_end may have restarted the scan.
if (this->is_ready() && !this->scan_running_ && !this->pending_start_) {
this->disable_loop();
}
}
// Stamp-and-start for every controller scan attempt: the stamp keeps the
// SCAN_START_RETRY_MS floor covering all callers, not only loop()'s retry.
bool RP2BLETracker::controller_scan_start_() {
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
const bool ok = this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
this->last_start_failed_ = !ok;
return ok;
}
void RP2BLETracker::start_scan_() {
@@ -208,19 +260,15 @@ void RP2BLETracker::start_scan_() {
return;
this->scan_running_ = true;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
// Symmetric with stop_scan_()'s stop log; asymmetry would read as the
// scanner failing to come back.
ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)",
this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"),
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS);
// Re-anchor the scan period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
// arrive). Same clock as loop()'s `now`: a fresh millis() here would be ahead of
// the cached loop time and make the same-iteration period check underflow.
// Anchor the period to the scan, not to boot, so a restart after a long gap
// does not fire on_scan_end immediately. Same clock as loop()'s now.
this->scan_period_start_ = App.get_loop_component_start_time();
this->scan_start_time_ = this->scan_period_start_;
}
void RP2BLETracker::stop_scan_() {
@@ -232,7 +280,9 @@ void RP2BLETracker::stop_scan_() {
this->fire_scan_end_();
// Reset the period clock so on_scan_end does not double-fire; same clock as loop().
this->scan_period_start_ = App.get_loop_component_start_time();
if (!this->scan_continuous_) {
// on_scan_end runs synchronously and may restart the scan; re-check before
// parking or that scan runs untimed.
if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) {
// Nothing left to time; start_scan() re-enables the loop.
this->disable_loop();
}
@@ -44,11 +44,20 @@ class RP2BLETracker : public Component,
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
void set_configured_continuous(bool scan_continuous) {
this->configured_continuous_ = scan_continuous;
this->scan_continuous_ = scan_continuous;
}
bool scan_continuous() const { return this->scan_continuous_; }
bool configured_continuous() const { return this->configured_continuous_; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// Re-anchors the one-shot duration clock only (bk72xx parity); no-op while
// idle. Policy lives in the action.
void restart_scan_duration();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
@@ -58,10 +67,8 @@ class RP2BLETracker : public Component,
this->dispatcher_.set_raw_advertisement_callback(callback);
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// BTstack delivers scan responses as separate advertisement reports; this
// tracker merges the pair before delivery (shared ScanResponseMerger,
// Bluedroid semantics). GATT is available when the BTstack connection
// backend is compiled in (bluetooth_proxy active).
// Scan responses arrive separately and are merged before delivery
// (Bluedroid semantics). GATT needs the BTstack connection backend.
#ifdef USE_BLE_GATT_CLIENT
constexpr bool has_gatt = true;
#else
@@ -77,8 +84,7 @@ class RP2BLETracker : public Component,
bool request_scan_mode(bool active);
// ---- rp2040_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main loop — the
// IRQ → main-loop handoff already happened in the controller's queue.
// Delivered on the main loop; the controller's queue did the IRQ handoff.
void on_scan_report(const rp2040_ble::BLEScanReport &report) override;
protected:
@@ -93,20 +99,29 @@ class RP2BLETracker : public Component,
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000};
uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries
uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end()
bool scan_running_{false};
bool scan_active_{true};
uint32_t scan_period_start_{0}; // continuous-mode on_scan_end period clock
uint32_t scan_start_time_{0}; // one-shot duration clock (bk72xx parity: kept separate from the period)
// Bit-packed (C++20 default member initializers on bit-fields);
// scan_continuous_ stays a plain bool because the merger binds its address.
bool scan_running_ : 1 {false};
bool pending_start_ : 1 {false}; // start_scan() latched before setup() or while the stack is
// not ACTIVE; loop() applies it once it is
bool last_start_failed_ : 1 {false}; // last controller start failed; gates the public start_scan() floor
bool scan_active_ : 1 {true};
bool configured_continuous_ : 1 {true}; // YAML scan_parameters.continuous; runtime stop_scan() must not lose it
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure
// Resume intent for a failed/aborted OTA: seeded at OTA start, overwritten
// by a start/stop during the download, except from the pause's own stop.
bool scan_continuous_before_ota_ : 1 {false}; // resume continuous
bool scan_pending_before_ota_ : 1 {false}; // resume a one-shot scan
bool ota_in_progress_ : 1 {false}; // OTA holds the radio; start_scan() defers to the resume path
bool ota_pausing_ : 1 {false}; // inside the OTA's own stop_scan(); its latch clear is skipped
#endif
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main loop. Merger clock: stash_adv() reads the
// PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue
// drain), sweep() this component's — same App.loop() pass, so the delta
// stays non-negative and the 300 ms timeout holds.
// Shared merge + dispatch (ble_device_base), all on the main loop.
// stash_adv() uses the parent's cached loop time and sweep() this one's -
// same App.loop() pass, so the merger delta stays non-negative.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
};
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::sensor {
static const char *const TAG = "sensor.automation";
} // namespace esphome::sensor
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@NoQuarrel"]
+79
View File
@@ -0,0 +1,79 @@
import esphome.codegen as cg
from esphome.components import i2c, sensirion_common, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_FORMALDEHYDE,
CONF_HUMIDITY,
CONF_ID,
CONF_TEMPERATURE,
DEVICE_CLASS_GAS,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
ICON_FLASK_OUTLINE,
ICON_THERMOMETER,
ICON_WATER_PERCENT,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_PARTS_PER_BILLION,
UNIT_PERCENT,
)
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["sensirion_common"]
CONF_WAIT_FOR_READY = "wait_for_ready"
sfa40_ns = cg.esphome_ns.namespace("sfa40")
SFA40Component = sfa40_ns.class_(
"SFA40Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(SFA40Component),
cv.Optional(CONF_WAIT_FOR_READY, default=True): cv.boolean,
cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_FLASK_OUTLINE,
accuracy_decimals=1,
device_class=DEVICE_CLASS_GAS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_WATER_PERCENT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x5D))
)
SENSOR_MAP = {
CONF_FORMALDEHYDE: "set_formaldehyde_sensor",
CONF_TEMPERATURE: "set_temperature_sensor",
CONF_HUMIDITY: "set_humidity_sensor",
}
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY]))
for key, func_name in SENSOR_MAP.items():
if sensor_config := config.get(key):
sens = await sensor.new_sensor(sensor_config)
cg.add(getattr(var, func_name)(sens))
+159
View File
@@ -0,0 +1,159 @@
#include "sfa40.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::sfa40 {
static const char *const TAG = "sfa40";
// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf
static const uint16_t SFA40_CMD_START_MEASUREMENT = 0x00AC;
static const uint16_t SFA40_CMD_STOP_MEASUREMENT = 0x50D2;
static const uint16_t SFA40_CMD_READ_MEASURE_PROD = 0xC0EB;
// B4 (engineering-sample) command codes. Commands from here: https://github.com/DFRobot/DFRobot_SFA40
static const uint16_t SFA40_CMD_READ_MEASURE_B4 = 0xE06D;
static const uint16_t SFA40_CMD_READ_ID_PROD = 0x02CE;
static const uint16_t SFA40_CMD_READ_ID_B4 = 0x0559;
static const uint8_t STATUS_NOT_READY = 0x01;
static const uint8_t STATUS_OUT_OF_SPEC = 0x02;
static uint64_t raw_to_serial(const uint16_t *raw, size_t words) {
uint64_t serial = 0;
for (size_t i = 0; i < words; i++) {
serial = (serial << 16) | raw[i];
}
return serial;
}
static void raw_to_marking(const uint16_t *raw, size_t words, char *out, size_t out_len) {
if (out_len < words * 2 + 1) {
return;
}
for (size_t i = 0; i < words; i++) {
out[i * 2] = static_cast<char>(raw[i] >> 8);
out[i * 2 + 1] = static_cast<char>(raw[i] & 0xFF);
}
out[words * 2] = '\0';
}
void SFA40Component::setup() {
this->write_command(SFA40_CMD_STOP_MEASUREMENT);
this->set_timeout(25, [this]() {
if (!this->detect_protocol_()) {
ESP_LOGE(TAG, "Failed to detect SFA40 protocol");
this->error_code_ = PROTOCOL_DETECTION_FAILED;
this->mark_failed();
return;
}
if (!this->write_command(SFA40_CMD_START_MEASUREMENT)) {
ESP_LOGE(TAG, "Failed to start measurements");
this->error_code_ = MEASUREMENT_INIT_FAILED;
this->mark_failed();
return;
}
this->initialized_ = true;
ESP_LOGD(TAG, "Measurement started");
});
}
bool SFA40Component::detect_protocol_() {
uint16_t raw[5] = {};
if (this->get_register(SFA40_CMD_READ_ID_PROD, raw, 3, 5)) {
this->protocol_version_ = ProtocolVersion::PRODUCTION;
this->serial_number_ = raw_to_serial(raw, 3);
ESP_LOGD(TAG, "Detected production SFA40, serial number: %012" PRIX64, this->serial_number_);
return true;
}
if (this->get_register(SFA40_CMD_READ_ID_B4, raw, 5, 5)) {
this->protocol_version_ = ProtocolVersion::PROTOTYPE;
raw_to_marking(raw, 5, this->device_marking_, sizeof(this->device_marking_));
ESP_LOGD(TAG, "Detected engineering-sample SFA40, marking: '%s'", this->device_marking_);
return true;
}
return false;
}
void SFA40Component::dump_config() {
ESP_LOGCONFIG(TAG, "sfa40:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
switch (this->error_code_) {
case PROTOCOL_DETECTION_FAILED:
ESP_LOGW(TAG, "Protocol detection failed!");
break;
case MEASUREMENT_INIT_FAILED:
ESP_LOGW(TAG, "Measurement initialization failed!");
break;
default:
ESP_LOGW(TAG, "Unknown setup error!");
break;
}
}
LOG_UPDATE_INTERVAL(this);
switch (this->protocol_version_) {
case ProtocolVersion::PRODUCTION:
ESP_LOGCONFIG(TAG, " Protocol: production\n Serial Number: %012" PRIX64, this->serial_number_);
break;
case ProtocolVersion::PROTOTYPE:
ESP_LOGCONFIG(TAG, " Protocol: prototype (B4)\n Marking: '%s'", this->device_marking_);
break;
default:
ESP_LOGCONFIG(TAG, " Protocol: (detecting...)");
break;
}
ESP_LOGCONFIG(TAG, " Wait for ready: %s", YESNO(this->wait_for_ready_));
LOG_SENSOR(" ", "Formaldehyde", this->formaldehyde_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
}
void SFA40Component::update() {
if (!this->initialized_ || this->protocol_version_ == ProtocolVersion::UNKNOWN) {
return;
}
const uint16_t read_cmd = (this->protocol_version_ == ProtocolVersion::PRODUCTION) ? SFA40_CMD_READ_MEASURE_PROD
: SFA40_CMD_READ_MEASURE_B4;
if (!this->write_command(read_cmd)) {
ESP_LOGW(TAG, "Error reading measurement");
this->status_set_warning();
return;
}
this->set_timeout(5, [this]() {
uint16_t raw[4];
if (!this->read_data(raw, 4)) {
ESP_LOGW(TAG, "Error reading measurement data");
this->status_set_warning();
return;
}
const uint8_t status = raw[3] >> 8;
const bool sensor_not_ready = (status & STATUS_NOT_READY) != 0;
const bool sensor_out_of_spec = (status & STATUS_OUT_OF_SPEC) != 0;
if (this->formaldehyde_sensor_ != nullptr) {
if (sensor_out_of_spec) {
ESP_LOGW(TAG, "Skipping formaldehyde publish: sensor out of spec (status=0x%02X)", status);
} else if (this->wait_for_ready_ && sensor_not_ready) {
ESP_LOGD(TAG, "Skipping formaldehyde publish: sensor warming up");
} else {
this->formaldehyde_sensor_->publish_state(static_cast<float>(raw[0]) / 10.0f);
}
}
if (this->humidity_sensor_ != nullptr) {
this->humidity_sensor_->publish_state(clamp(125.0f * static_cast<float>(raw[1]) / 65535.0f - 6.0f, 0.0f, 100.0f));
}
if (this->temperature_sensor_ != nullptr) {
this->temperature_sensor_->publish_state(175.0f * (static_cast<float>(raw[2]) / 65535.0f) - 45.0f);
}
this->status_clear_warning();
});
}
} // namespace esphome::sfa40
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/sensirion_common/i2c_sensirion.h"
namespace esphome::sfa40 {
// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf
class SFA40Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice {
public:
void setup() override;
void dump_config() override;
void update() override;
void set_formaldehyde_sensor(sensor::Sensor *formaldehyde) { this->formaldehyde_sensor_ = formaldehyde; }
void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; }
void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; }
void set_wait_for_ready(bool wait_for_ready) { this->wait_for_ready_ = wait_for_ready; }
protected:
enum ProtocolVersion : uint8_t {
UNKNOWN = 0,
PRODUCTION = 1,
PROTOTYPE = 2,
};
enum ErrorCode : uint8_t {
UNKNOWN_ERROR = 0,
PROTOCOL_DETECTION_FAILED,
MEASUREMENT_INIT_FAILED,
};
bool detect_protocol_();
ProtocolVersion protocol_version_{UNKNOWN};
ErrorCode error_code_{UNKNOWN_ERROR};
char device_marking_[11]{};
bool initialized_{false};
bool wait_for_ready_{true};
uint64_t serial_number_{0};
sensor::Sensor *formaldehyde_sensor_{nullptr};
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
};
} // namespace esphome::sfa40
+2
View File
@@ -352,6 +352,8 @@ class SPIComponent final : public Component {
this->using_hw_ = true;
}
SPIInterface get_interface() const { return this->interface_; }
void set_interface_name(const char *name) { this->interface_name_ = name; }
float get_setup_priority() const override { return setup_priority::BUS; }
-8
View File
@@ -1,8 +0,0 @@
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
static const char *const TAG = "switch.automation";
} // namespace esphome::switch_
+19 -2
View File
@@ -9,6 +9,7 @@ from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_AT,
@@ -425,7 +426,12 @@ async def setup_time_core_(time_var, config):
raise EsphomeError(f"Invalid timezone: {timezone}") from e
_emit_parsed_timezone_fields(parsed)
for conf in config.get(CONF_ON_TIME, []):
on_time = config.get(CONF_ON_TIME, [])
on_time_sync = config.get(CONF_ON_TIME_SYNC, [])
if on_time or on_time_sync:
cg.add_define("USE_TIME_TRIGGERS")
for conf in on_time:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
seconds = conf.get(CONF_SECONDS, list(range(61)))
@@ -444,7 +450,7 @@ async def setup_time_core_(time_var, config):
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_TIME_SYNC, []):
for conf in on_time_sync:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
await cg.register_component(trigger, conf)
@@ -475,3 +481,14 @@ async def to_code(config):
async def time_has_time_to_code(config, condition_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(condition_id, template_arg, paren)
# posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a
# timezone is configured or detected; automation.cpp holds the on_time and
# on_time_sync triggers and is #ifdef'd on USE_TIME_TRIGGERS.
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"posix_tz.cpp": "USE_TIME_TIMEZONE",
"automation.cpp": "USE_TIME_TRIGGERS",
}
)
+3
View File
@@ -1,4 +1,5 @@
#include "automation.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/log.h"
@@ -98,3 +99,5 @@ SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) {
}
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
+5
View File
@@ -1,5 +1,8 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_TIME_TRIGGERS
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/time.h"
@@ -49,3 +52,5 @@ class SyncTrigger final : public Trigger<>, public Component {
RealTimeClock *rtc_;
};
} // namespace esphome::time
#endif // USE_TIME_TRIGGERS
+2 -1
View File
@@ -61,8 +61,9 @@ async def to_code(config: ConfigType) -> None:
if time_id_config := config.get(CONF_TIME_ID):
time_id = await cg.get_variable(time_id_config)
cg.add(var.set_time(time_id))
cg.add_define("USE_UPTIME_TIMESTAMP")
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"uptime_timestamp_sensor.cpp": "USE_TIME"}
{"uptime_timestamp_sensor.cpp": "USE_UPTIME_TIMESTAMP"}
)
@@ -1,6 +1,6 @@
#include "uptime_timestamp_sensor.h"
#ifdef USE_TIME
#ifdef USE_UPTIME_TIMESTAMP
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -34,4 +34,4 @@ void UptimeTimestampSensor::dump_config() {
} // namespace esphome::uptime
#endif // USE_TIME
#endif // USE_UPTIME_TIMESTAMP
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_TIME
#ifdef USE_UPTIME_TIMESTAMP
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/time/real_time_clock.h"
@@ -25,4 +25,4 @@ class UptimeTimestampSensor final : public sensor::Sensor, public Component {
} // namespace esphome::uptime
#endif // USE_TIME
#endif // USE_UPTIME_TIMESTAMP
@@ -20,6 +20,7 @@ async def to_code(config: ConfigType) -> None:
# Re-enable esp-tls (excluded by default to save compile time);
# web_server_idf.cpp includes <esp_tls_crypto.h> for digest auth
include_builtin_idf_component("esp-tls")
include_builtin_idf_component("esp_http_server")
# multipart.cpp is fully #ifdef'd on USE_WEBSERVER_OTA (set by the
+8
View File
@@ -951,6 +951,14 @@ class WiFiComponent final : public Component {
// On ESP8266, written from SDK system context (wifi_event_callback) —
// uint8_t writes are atomic on Xtensa LX106 so no synchronization is needed.
uint8_t sta_state_{0};
#endif
#ifdef USE_LIBRETINY
// First attempt since STA-up (re-armed on every STA off->on); the
// pre-attempt teardown is skipped then.
bool lt_first_connect_attempt_{true};
// A self-inflicted disconnect from that teardown is pending; it must not
// consume an ignored-disconnect slot.
bool lt_teardown_event_pending_{false};
#endif
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
RoamingState roaming_state_{RoamingState::IDLE};
@@ -115,6 +115,8 @@ bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
if (enable_sta && !current_sta) {
ESP_LOGV(TAG, "Enabling STA");
// Fresh STA stack: skip the pre-attempt teardown again.
this->lt_first_connect_attempt_ = true;
} else if (!enable_sta && current_sta) {
ESP_LOGV(TAG, "Disabling STA");
}
@@ -202,10 +204,21 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
if (!this->wifi_mode_(true, {}))
return false;
String ssid = WiFi.SSID();
if (ssid && strcmp(ssid.c_str(), ap.ssid_.c_str()) != 0) {
WiFi.disconnect();
// Tear down any live session so begin() re-fires its events; skipped on the
// first attempt after STA-up (nothing to tear down, and BK7231N on the older
// Beken SDK did not come back from it). The flag is per-attempt and armed
// only for a live session: an idle disconnect may emit no event, and a stale
// flag would swallow this attempt's first real failure.
this->lt_teardown_event_pending_ = false;
if (!this->lt_first_connect_attempt_) {
const bool was_live = WiFi.status() == WL_CONNECTED;
if (WiFi.disconnect()) {
this->lt_teardown_event_pending_ = was_live;
} else {
ESP_LOGD(TAG, "Pre-connect teardown returned false");
}
}
this->lt_first_connect_attempt_ = false;
#ifdef USE_WIFI_MANUAL_IP
if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) {
@@ -227,7 +240,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
ap.get_channel(), // 0 = auto
ap.has_bssid() ? ap.get_bssid().data() : NULL);
if (status != WL_CONNECTED) {
ESP_LOGW(TAG, "esp_wifi_connect failed: %d", status);
ESP_LOGW(TAG, "WiFi.begin failed: %d", status);
// Without this reset the state machine stays at CONNECTING and each retry
// stalls for the full connect timeout (46 s).
this->sta_state_ = static_cast<uint8_t>(LTWiFiSTAState::ERROR_FAILED);
return false;
}
@@ -455,6 +471,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: {
// Processed in queue order, so a teardown event still ahead of this
// CONNECTED was already consumed; a leftover flag is stale.
this->lt_teardown_event_pending_ = false;
auto &it = event->data.sta_connected;
char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(it.bssid, bssid_buf);
@@ -482,6 +501,14 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: {
auto &it = event->data.sta_disconnected;
// Consume the disconnect our own teardown queued, without spending an
// ignore slot. Ungated on SSID and state: the flag is armed only for this
// attempt's teardown of a live session.
if (this->lt_teardown_event_pending_ && it.reason != WIFI_REASON_NO_AP_FOUND) {
this->lt_teardown_event_pending_ = false;
break;
}
// LibreTiny can send spurious disconnect events with empty ssid/bssid during connection.
// These are typically "Association Leave" events that don't indicate actual failures:
// [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave'
+2
View File
@@ -188,10 +188,12 @@
#define USE_TEXT_SENSOR
#define USE_TEXT_SENSOR_FILTER
#define USE_TIME
#define USE_TIME_TRIGGERS
#define USE_TOUCHSCREEN
#define USE_UART_DEBUGGER
#define USE_UART_WAKE_LOOP_ON_RX
#define USE_UPDATE
#define USE_UPTIME_TIMESTAMP
#define USE_VALVE
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
+4 -2
View File
@@ -239,7 +239,9 @@ template<typename R, typename F> inline R parse_number(const StringRef &str, siz
}
// NOLINTEND(google-runtime-int)
} // namespace internal
// NOLINTBEGIN(readability-identifier-naming,google-runtime-int)
// readability-non-const-parameter: `pos` is written through by internal::parse_number, one call
// frame away; the check only inspects these bodies, so it wrongly proposes `const size_t *`.
// NOLINTBEGIN(readability-identifier-naming,google-runtime-int,readability-non-const-parameter)
inline int stoi(const StringRef &str, size_t *pos = nullptr, int base = 10) {
return static_cast<int>(internal::parse_number<long>(str, pos, base, std::strtol));
}
@@ -252,7 +254,7 @@ inline float stof(const StringRef &str, size_t *pos = nullptr) {
inline double stod(const StringRef &str, size_t *pos = nullptr) {
return internal::parse_number<double>(str, pos, std::strtod);
}
// NOLINTEND(readability-identifier-naming,google-runtime-int)
// NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter)
#ifdef USE_JSON
// NOLINTNEXTLINE(readability-identifier-naming)
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code
+8
View File
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
CORE.reset()
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -0,0 +1,9 @@
esphome:
name: test
libraries:
- NetworkClientSecure
esp32:
board: esp32dev
framework:
type: arduino
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
use_full_certificate_bundle: true
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE: y
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
esp32_camera_web_server:
port: 8080
mode: stream
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: y
CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC: y
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID: "0"
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: n
+87 -1
View File
@@ -17,6 +17,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
@@ -274,9 +275,24 @@ def test_esp32_configuration_errors(
),
pytest.param(
"exclusion_reincludes_web_server.yaml",
("esp-tls",),
("esp-tls", "esp_http_server"),
id="web_server_idf",
),
pytest.param(
"nvs_encryption_s3.yaml",
("nvs_sec_provider",),
id="nvs_encryption",
),
pytest.param(
"exclusion_reincludes_nvs_sdkconfig.yaml",
("nvs_sec_provider",),
id="nvs_encryption_raw_sdkconfig",
),
pytest.param(
"exclusion_reincludes_camera_web_server.yaml",
("esp_http_server",),
id="esp32_camera_web_server",
),
pytest.param(
"exclusion_reincludes_nextion.yaml",
("esp-tls", "esp_http_client"),
@@ -304,6 +320,76 @@ def test_default_exclusions_reincluded_by_owning_components(
# Components no part of this config touches stay excluded.
assert "unity" in excluded
assert "fatfs" in excluded
# The HTTP server only comes back for configs that run one.
assert ("esp_http_server" in excluded) == ("esp_http_server" not in reincluded)
def test_nvs_sec_provider_stays_excluded_when_encryption_is_off(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
_BUNDLE_OPTIONS = (
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL",
)
@pytest.mark.parametrize(
("config_file", "expected"),
[
pytest.param("exclusion_reincludes.yaml", (False, None, None), id="no_tls"),
pytest.param(
"certificate_bundle_http_request.yaml",
(True, True, False),
id="http_request",
),
pytest.param(
"exclusion_reincludes_http_request.yaml",
(False, None, None),
id="http_request_no_verify",
),
pytest.param(
"certificate_bundle_full.yaml", (True, None, True), id="full_option"
),
pytest.param(
"certificate_bundle_arduino_tls.yaml",
(True, True, False),
id="arduino_network_client_secure",
),
],
)
def test_certificate_bundle_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: tuple[bool | None, ...],
) -> None:
"""The bundle and its CMN/FULL variant are written only when requested."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _BUNDLE_OPTIONS) == expected
def test_user_sdkconfig_certificate_bundle_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A raw sdkconfig_options bundle setting is kept and still pins CMN."""
generate_main(component_config_path("certificate_bundle_sdkconfig.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
value = sdkconfig["CONFIG_MBEDTLS_CERTIFICATE_BUNDLE"]
assert isinstance(value, RawSdkconfigValue)
assert value.value == "y"
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN") is True
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
def test_execute_from_psram_s3_sdkconfig(
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
- id: spi_bus
interface: spi2
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
ethernet:
id: eth_component
type: W5500
spi_id: spi_bus
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
ethernet:
id: eth_component
type: W5500
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -27,14 +27,6 @@ _CH390_CONFIG = {
}
@pytest.fixture(autouse=True)
def _reset_full_config():
"""Reset fv.full_config so each test starts with a clean slate."""
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
@@ -0,0 +1,235 @@
"""Tests for the ethernet `spi_id:` option (attach to a shared spi bus)."""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_INTERFACE, CONFIG_SCHEMA, _final_validate
from esphome.components.rp2.const import KEY_BOARD as RP2_KEY_BOARD
# Registers the rp2 pin schema so RP2 configs can validate pins.
import esphome.components.rp2.gpio # noqa: F401
from esphome.components.spi import CONF_INTERFACE_INDEX
from esphome.const import (
CONF_CLK_PIN,
CONF_ID,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_SPI,
CONF_SPI_ID,
CONF_TYPE,
PlatformFramework,
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from ..types import SetCoreConfigCallable
_W5500_PIN_CONFIG = {
"type": "W5500",
"clk_pin": 47,
"mosi_pin": 48,
"miso_pin": 14,
"cs_pin": 21,
}
_W5500_SPI_ID_CONFIG = {
"type": "W5500",
"spi_id": "spi_bus",
"cs_pin": 21,
}
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "spi-id-test"
def test_spi_id_accepted_without_pins_or_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
"""With spi_id set, the pin options are not required and no interface is defaulted."""
_set_esp32_s3(set_core_config)
config = CONFIG_SCHEMA(dict(_W5500_SPI_ID_CONFIG))
assert config[CONF_SPI_ID] == ID("spi_bus")
# The interface comes from the referenced bus; no default may be injected.
assert CONF_INTERFACE not in config
@pytest.mark.parametrize(
("key", "value"),
[
(CONF_CLK_PIN, 47),
(CONF_MOSI_PIN, 48),
(CONF_MISO_PIN, 14),
(CONF_INTERFACE, "spi2"),
],
)
def test_spi_id_rejects_bus_options(
set_core_config: SetCoreConfigCallable, key: str, value: int | str
) -> None:
"""Options provided by the referenced bus must be rejected alongside spi_id."""
_set_esp32_s3(set_core_config)
with pytest.raises(Invalid, match=f"'{key}' cannot be used together with 'spi_id'"):
CONFIG_SCHEMA({**_W5500_SPI_ID_CONFIG, key: value})
@pytest.mark.parametrize("key", [CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN])
def test_bus_pins_still_required_without_spi_id(
set_core_config: SetCoreConfigCallable, key: str
) -> None:
"""Without spi_id, the bus pin options stay required."""
_set_esp32_s3(set_core_config)
config = {k: v for k, v in _W5500_PIN_CONFIG.items() if k != key}
with pytest.raises(
Invalid, match=f"'{key}' is a required option when 'spi_id' is not set"
):
CONFIG_SCHEMA(config)
def test_spi_id_rejected_on_rp2(set_core_config: SetCoreConfigCallable) -> None:
"""spi_id is ESP32-only; the RP2 path is unchanged."""
set_core_config(
PlatformFramework.RP2_ARDUINO, platform_data={RP2_KEY_BOARD: "rpipicow"}
)
CORE.name = "spi-id-test"
config = {
"type": "W5500",
"spi_id": "spi_bus",
"clk_pin": 18,
"mosi_pin": 19,
"miso_pin": 16,
"cs_pin": 17,
}
with pytest.raises(Invalid, match="only available on"):
CONFIG_SCHEMA(config)
def _eth_spi_id_final_config() -> dict:
return {CONF_TYPE: "W5500", CONF_SPI_ID: ID("spi_bus")}
class _FakeFinalConfig(dict):
"""Dict-backed FinalValidateConfig with just enough ID resolution for
fv.id_declaration_match_schema to find an spi bus fragment."""
def get_path_for_id(self, id: ID) -> list:
for index, conf in enumerate(self[CONF_SPI]):
if conf[CONF_ID] == id:
return [CONF_SPI, index, CONF_ID]
raise KeyError(id)
def get_config_for_path(self, path: list) -> dict:
return self[path[0]][path[1]]
def _set_spi_buses(*buses: dict) -> None:
fv.full_config.set(_FakeFinalConfig({CONF_SPI: list(buses)}))
_SHAREABLE_BUS = {
CONF_ID: ID("spi_bus"),
CONF_INTERFACE_INDEX: 0,
CONF_MISO_PIN: {},
CONF_MOSI_PIN: {},
}
def test_final_validate_accepts_hardware_bus_with_data_pins(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A hardware spi bus that declares miso_pin and mosi_pin may be shared."""
_set_esp32_s3(set_core_config)
# An unrelated bus first: the ID lookup must skip past it.
_set_spi_buses({CONF_ID: ID("other_bus"), CONF_INTERFACE_INDEX: 1}, _SHAREABLE_BUS)
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_software_bus(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A software spi bus (no hardware interface index) cannot be shared."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != CONF_INTERFACE_INDEX}
_set_spi_buses(bus)
with pytest.raises(Invalid, match="requires this spi bus to use a hardware"):
_final_validate(_eth_spi_id_final_config())
@pytest.mark.parametrize("pin_key", [CONF_MISO_PIN, CONF_MOSI_PIN])
def test_final_validate_rejects_bus_without_data_pin(
set_core_config: SetCoreConfigCallable, pin_key: str
) -> None:
"""The shared bus must declare both data pins to drive the ethernet chip."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != pin_key}
_set_spi_buses(bus)
with pytest.raises(Invalid, match=f"requires this spi bus to declare a {pin_key}"):
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_colliding_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, claiming the same host as an spi bus stays an error."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
config = {CONF_TYPE: "W5500", CONF_INTERFACE: "spi2"}
with pytest.raises(Invalid, match="both using interface 'SPI2_HOST'"):
_final_validate(config)
def test_final_validate_accepts_distinct_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, a different host than the spi bus is accepted."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
_final_validate({CONF_TYPE: "W5500", CONF_INTERFACE: "spi3"})
def test_generated_code_uses_spi_parent(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With spi_id, codegen wires the spi parent and skips the bus options."""
main_cpp = generate_main(component_config_path("spi_id_shared_bus.yaml"))
assert "eth_component->set_spi_parent(spi_bus);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_clk_pin(" not in main_cpp
assert "eth_component->set_miso_pin(" not in main_cpp
assert "eth_component->set_mosi_pin(" not in main_cpp
assert "eth_component->set_interface(" not in main_cpp
def test_generated_code_without_spi_id_initializes_own_bus(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without spi_id, codegen still emits the pin and interface setters."""
main_cpp = generate_main(component_config_path("spi_own_bus.yaml"))
assert "eth_component->set_spi_parent(" not in main_cpp
assert "eth_component->set_clk_pin(18);" in main_cpp
assert "eth_component->set_miso_pin(19);" in main_cpp
assert "eth_component->set_mosi_pin(23);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_interface(::SPI3_HOST);" in main_cpp
@@ -24,11 +24,9 @@ from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def _clear_core_data():
"""Wipe CORE.data and reset fv.full_config so each test starts clean."""
"""Wipe CORE.data so each test starts clean."""
CORE.data.clear()
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
CORE.data.clear()
@@ -0,0 +1,45 @@
esphome:
name: rp2-trigger-codegen
on_boot:
then:
- rp2_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- rp2_ble_tracker.start_scan:
- rp2_ble_tracker.stop_scan
rp2:
board: rpipicow
rp2_ble_tracker:
scan_parameters:
continuous: false
active: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,60 @@
"""Codegen tests for the tracker automations.
The shared trigger classes (ble_device_base/automation.h) are compiled by the
rp2040 compile fixtures, but the codegen accounting the getattr-built setter
spellings, the single set_continuous pin and the listener-count define is
only checkable from the generated main, mirroring the bk72xx/ln882h tests."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_automations.yaml"))
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
assert (
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# active: false (non-default) flows through to the setter.
assert "->set_scan_active(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time:
- seconds: 0
then:
- logger.log: tick
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time_sync:
then:
- logger.log: synced
@@ -0,0 +1,28 @@
"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an
on_time or on_time_sync automation exists, so the define must follow them."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", True),
],
)
def test_triggers_define_follows_automations(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_TIME_TRIGGERS" in defines) is emits
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Seconds
type: seconds
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Timestamp
type: timestamp
@@ -0,0 +1,27 @@
"""The timestamp uptime sensor source is only compiled when that type is used,
so the define must follow the configured sensor type rather than time: alone."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("seconds.yaml", False),
("timestamp.yaml", True),
],
)
def test_timestamp_define_follows_sensor_type(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_UPTIME_TIMESTAMP" in defines) is emits
+1 -1
View File
@@ -7,7 +7,7 @@ esp32:
enable_lwip_mdns_queries: true
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
use_full_certificate_bundle: false # Test CMN bundle (default)
use_full_certificate_bundle: false # Bundle stays off without a component that needs it
include_builtin_idf_components:
- freertos # Test escape hatch (freertos is always included anyway)
enable_full_printf: false
@@ -0,0 +1,17 @@
ethernet:
type: W5500
spi_id: spi_bus
cs_pin: 5
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
ethernet: !include common-w5500-spi-id.yaml
@@ -0,0 +1,54 @@
esphome:
on_boot:
then:
- rp2_ble_tracker.start_scan
- rp2_ble_tracker.start_scan:
continuous: true
# Lambda arm of the templatable value — different codegen instantiation.
- rp2_ble_tracker.start_scan:
continuous: !lambda return false;
- rp2_ble_tracker.stop_scan
- rp2_ble_tracker.stop_scan: ble_tracker
rp2_ble_tracker:
on_ble_advertise:
- mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
- mac_address:
- AC:37:43:77:5F:4C
- AC:37:43:77:5F:4D
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
on_ble_service_data_advertise:
- service_uuid: ABCD
# mac_address exercises the UUID triggers' set_address() codegen branch.
mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
ESP_LOGD("main", "Length of service data is %zu", x.size());
- service_uuid: ABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "32-bit service data is %zu", x.size());
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit service data is %zu", x.size());
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: |-
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size());
on_scan_end:
- then:
- lambda: |-
ESP_LOGD("main", "Scan ended");
@@ -0,0 +1,3 @@
packages:
rp2_ble_tracker: !include common.yaml
automations: !include common-automations.yaml
+12
View File
@@ -0,0 +1,12 @@
sensor:
- platform: sfa40
i2c_id: i2c_bus
wait_for_ready: false
formaldehyde:
name: SFA40 formaldehyde
temperature:
name: SFA40 temperature
humidity:
name: SFA40 humidity
address: 0x5D
update_interval: 30s
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sfa40: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
sfa40: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
sfa40: !include common.yaml
@@ -0,0 +1,9 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sensor:
- platform: sfa40
i2c_id: i2c_bus
wait_for_ready: true
formaldehyde:
name: SFA40 formaldehyde
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code