diff --git a/CODEOWNERS b/CODEOWNERS index 3047072ea2..b898788b1a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 9f27d51059..d46f01838e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 \ diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 277df0506a..2a5304be77 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -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", diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cde0cfd68b..073d87402a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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): @@ -1735,6 +1767,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. @@ -1744,6 +1786,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 @@ -2160,6 +2203,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() @@ -2218,6 +2265,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. @@ -2229,37 +2301,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 @@ -2270,7 +2336,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) @@ -2295,29 +2361,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 @@ -2330,15 +2391,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) @@ -2525,21 +2586,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( @@ -2929,6 +2980,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( @@ -2956,6 +3010,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 diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 09f458c64b..e7d8a66e7a 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -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" diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index 55ace66681..d54d5c6937 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -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") diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index a44a609d3c..0454440f14 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -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 diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 1482e7a828..2b67b9093b 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -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 diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 069478e70c..1d9903271e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -59,6 +59,9 @@ #ifdef USE_ETHERNET_SPI #include #include +#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_); diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 8a5aae022a..2abf097aec 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -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", diff --git a/esphome/components/output/automation.cpp b/esphome/components/output/automation.cpp deleted file mode 100644 index 610da897d9..0000000000 --- a/esphome/components/output/automation.cpp +++ /dev/null @@ -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 diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 7709df9899..b744aee31c 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -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 diff --git a/esphome/components/rp2_ble_tracker/automation.h b/esphome/components/rp2_ble_tracker/automation.h new file mode 100644 index 0000000000..a3cede236a --- /dev/null +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -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 class StartScanAction final : public Action, public Parented { + 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 class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 06beb186ae..a0a04b981d 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -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(this->scan_interval_), - static_cast(this->scan_window_), this->scan_active_); + const bool ok = this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(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(); } diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 431f2daec7..af5250fe7e 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -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_; }; diff --git a/esphome/components/sensor/automation.cpp b/esphome/components/sensor/automation.cpp deleted file mode 100644 index 977719db9b..0000000000 --- a/esphome/components/sensor/automation.cpp +++ /dev/null @@ -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 diff --git a/esphome/components/sfa40/__init__.py b/esphome/components/sfa40/__init__.py new file mode 100644 index 0000000000..79568d458a --- /dev/null +++ b/esphome/components/sfa40/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@NoQuarrel"] diff --git a/esphome/components/sfa40/sensor.py b/esphome/components/sfa40/sensor.py new file mode 100644 index 0000000000..701660eb8f --- /dev/null +++ b/esphome/components/sfa40/sensor.py @@ -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)) diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp new file mode 100644 index 0000000000..0d6bee3f9a --- /dev/null +++ b/esphome/components/sfa40/sfa40.cpp @@ -0,0 +1,159 @@ +#include "sfa40.h" +#include "esphome/core/log.h" +#include + +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(raw[i] >> 8); + out[i * 2 + 1] = static_cast(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(raw[0]) / 10.0f); + } + } + + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->publish_state(clamp(125.0f * static_cast(raw[1]) / 65535.0f - 6.0f, 0.0f, 100.0f)); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(175.0f * (static_cast(raw[2]) / 65535.0f) - 45.0f); + } + + this->status_clear_warning(); + }); +} + +} // namespace esphome::sfa40 diff --git a/esphome/components/sfa40/sfa40.h b/esphome/components/sfa40/sfa40.h new file mode 100644 index 0000000000..6f67607753 --- /dev/null +++ b/esphome/components/sfa40/sfa40.h @@ -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 diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 0358ed278f..17c59c895a 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -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; } diff --git a/esphome/components/switch/automation.cpp b/esphome/components/switch/automation.cpp deleted file mode 100644 index 9a0221fe56..0000000000 --- a/esphome/components/switch/automation.cpp +++ /dev/null @@ -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_ diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 94ff6ab051..ecc448a96a 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -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", + } +) diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 3242669343..b91f8b0360 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -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 diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 7be195903a..5f980690a4 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -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 diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 4b611ffff3..a13d6cd7fd 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -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"} ) diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp index 4e0f06be1c..e8b8a21562 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp @@ -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 diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h index 5b837cbce1..c0e00a14fe 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h @@ -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 diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index c16b0a2833..5a400dfbf3 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -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 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 diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ada7be4ba4..382d3d5932 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -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}; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index e3c08416e8..63a63e7342 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -115,6 +115,8 @@ bool WiFiComponent::wifi_mode_(optional sta, optional 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(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' diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a8aab65d4a..971ad7c8d9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 33459f48af..2c7ec914c7 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -239,7 +239,9 @@ template 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(internal::parse_number(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(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) diff --git a/tests/benchmarks/components/time/__init__.py b/tests/benchmarks/components/time/__init__.py index 7f68003e29..8b4fd955f2 100644 --- a/tests/benchmarks/components/time/__init__.py +++ b/tests/benchmarks/components/time/__init__.py @@ -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 diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 3730978ec3..4f0b786cc2 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -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.""" diff --git a/tests/component_tests/esp32/config/certificate_bundle_arduino_tls.yaml b/tests/component_tests/esp32/config/certificate_bundle_arduino_tls.yaml new file mode 100644 index 0000000000..68f9cf1d0f --- /dev/null +++ b/tests/component_tests/esp32/config/certificate_bundle_arduino_tls.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + libraries: + - NetworkClientSecure + +esp32: + board: esp32dev + framework: + type: arduino diff --git a/tests/component_tests/esp32/config/certificate_bundle_full.yaml b/tests/component_tests/esp32/config/certificate_bundle_full.yaml new file mode 100644 index 0000000000..179fc12f51 --- /dev/null +++ b/tests/component_tests/esp32/config/certificate_bundle_full.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + use_full_certificate_bundle: true diff --git a/tests/component_tests/esp32/config/certificate_bundle_http_request.yaml b/tests/component_tests/esp32/config/certificate_bundle_http_request.yaml new file mode 100644 index 0000000000..b29e5de2bd --- /dev/null +++ b/tests/component_tests/esp32/config/certificate_bundle_http_request.yaml @@ -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 diff --git a/tests/component_tests/esp32/config/certificate_bundle_sdkconfig.yaml b/tests/component_tests/esp32/config/certificate_bundle_sdkconfig.yaml new file mode 100644 index 0000000000..af44693806 --- /dev/null +++ b/tests/component_tests/esp32/config/certificate_bundle_sdkconfig.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE: y diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_camera_web_server.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_camera_web_server.yaml new file mode 100644 index 0000000000..1bc5c936e0 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_camera_web_server.yaml @@ -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 diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_nvs_sdkconfig.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_nvs_sdkconfig.yaml new file mode 100644 index 0000000000..a82d4aaba8 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_nvs_sdkconfig.yaml @@ -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" diff --git a/tests/component_tests/esp32/config/exclusion_stays_nvs_sdkconfig_off.yaml b/tests/component_tests/esp32/config/exclusion_stays_nvs_sdkconfig_off.yaml new file mode 100644 index 0000000000..2580e77736 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_stays_nvs_sdkconfig_off.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ENCRYPTION: n diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 7208318d3a..0ffbe16a17 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -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, @@ -260,9 +261,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"), @@ -290,6 +306,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( diff --git a/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml b/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml new file mode 100644 index 0000000000..ffcd6a59f3 --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_id_shared_bus.yaml @@ -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 diff --git a/tests/component_tests/ethernet/config/spi_own_bus.yaml b/tests/component_tests/ethernet/config/spi_own_bus.yaml new file mode 100644 index 0000000000..3169e6a39a --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_own_bus.yaml @@ -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 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index 9308d0b099..522357f972 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -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": {}}) diff --git a/tests/component_tests/ethernet/test_spi_id.py b/tests/component_tests/ethernet/test_spi_id.py new file mode 100644 index 0000000000..e6dc9249e6 --- /dev/null +++ b/tests/component_tests/ethernet/test_spi_id.py @@ -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 diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index 017f0711a3..041b358dda 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -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() diff --git a/tests/component_tests/rp2_ble_tracker/__init__.py b/tests/component_tests/rp2_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml b/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..cf0d4f1567 --- /dev/null +++ b/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml @@ -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");' diff --git a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..fdcf2bcf75 --- /dev/null +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -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" diff --git a/tests/component_tests/time/config/no_triggers.yaml b/tests/component_tests/time/config/no_triggers.yaml new file mode 100644 index 0000000000..2df919df7e --- /dev/null +++ b/tests/component_tests/time/config/no_triggers.yaml @@ -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 diff --git a/tests/component_tests/time/config/on_time.yaml b/tests/component_tests/time/config/on_time.yaml new file mode 100644 index 0000000000..919a94f52d --- /dev/null +++ b/tests/component_tests/time/config/on_time.yaml @@ -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 diff --git a/tests/component_tests/time/config/on_time_sync.yaml b/tests/component_tests/time/config/on_time_sync.yaml new file mode 100644 index 0000000000..b5ddedce14 --- /dev/null +++ b/tests/component_tests/time/config/on_time_sync.yaml @@ -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 diff --git a/tests/component_tests/time/test_triggers.py b/tests/component_tests/time/test_triggers.py new file mode 100644 index 0000000000..34fd5a3157 --- /dev/null +++ b/tests/component_tests/time/test_triggers.py @@ -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 diff --git a/tests/component_tests/uptime/__init__.py b/tests/component_tests/uptime/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/uptime/config/seconds.yaml b/tests/component_tests/uptime/config/seconds.yaml new file mode 100644 index 0000000000..4d81a28011 --- /dev/null +++ b/tests/component_tests/uptime/config/seconds.yaml @@ -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 diff --git a/tests/component_tests/uptime/config/timestamp.yaml b/tests/component_tests/uptime/config/timestamp.yaml new file mode 100644 index 0000000000..e2eea374bb --- /dev/null +++ b/tests/component_tests/uptime/config/timestamp.yaml @@ -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 diff --git a/tests/component_tests/uptime/test_uptime.py b/tests/component_tests/uptime/test_uptime.py new file mode 100644 index 0000000000..d77f510659 --- /dev/null +++ b/tests/component_tests/uptime/test_uptime.py @@ -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 diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 6b77a4e171..523e614e24 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -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 diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml new file mode 100644 index 0000000000..14c01842f0 --- /dev/null +++ b/tests/components/ethernet/common-w5500-spi-id.yaml @@ -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!" diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml new file mode 100644 index 0000000000..16a25ced2a --- /dev/null +++ b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: !include common-w5500-spi-id.yaml diff --git a/tests/components/rp2_ble_tracker/common-automations.yaml b/tests/components/rp2_ble_tracker/common-automations.yaml new file mode 100644 index 0000000000..273cb5bb66 --- /dev/null +++ b/tests/components/rp2_ble_tracker/common-automations.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"); diff --git a/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml new file mode 100644 index 0000000000..6710ee4373 --- /dev/null +++ b/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + rp2_ble_tracker: !include common.yaml + automations: !include common-automations.yaml diff --git a/tests/components/sfa40/common.yaml b/tests/components/sfa40/common.yaml new file mode 100644 index 0000000000..d883e02cb6 --- /dev/null +++ b/tests/components/sfa40/common.yaml @@ -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 diff --git a/tests/components/sfa40/test.esp32-idf.yaml b/tests/components/sfa40/test.esp32-idf.yaml new file mode 100644 index 0000000000..ff65829346 --- /dev/null +++ b/tests/components/sfa40/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/test.esp8266-ard.yaml b/tests/components/sfa40/test.esp8266-ard.yaml new file mode 100644 index 0000000000..11c0bcbfc7 --- /dev/null +++ b/tests/components/sfa40/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/test.rp2040-ard.yaml b/tests/components/sfa40/test.rp2040-ard.yaml new file mode 100644 index 0000000000..7c7f8acdf6 --- /dev/null +++ b/tests/components/sfa40/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/validate.esp32-idf.yaml b/tests/components/sfa40/validate.esp32-idf.yaml new file mode 100644 index 0000000000..6f65ac4bdb --- /dev/null +++ b/tests/components/sfa40/validate.esp32-idf.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 diff --git a/tests/components/time/__init__.py b/tests/components/time/__init__.py index 7f68003e29..8b4fd955f2 100644 --- a/tests/components/time/__init__.py +++ b/tests/components/time/__init__.py @@ -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