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