From 923700dcc91751521b106827667e48fc87145a93 Mon Sep 17 00:00:00 2001 From: Francesco Pace Date: Wed, 7 Oct 2026 02:46:06 +0200 Subject: [PATCH] [espectre] Add Wi-Fi CSI motion detection component (#19801) --- CODEOWNERS | 1 + esphome/components/espectre/__init__.py | 185 +++++++++++ esphome/components/espectre/binary_sensor.py | 32 ++ .../components/espectre/button/__init__.py | 20 ++ .../espectre/button/espectre_button.h | 19 ++ esphome/components/espectre/espectre.cpp | 305 ++++++++++++++++++ esphome/components/espectre/espectre.h | 110 +++++++ .../components/espectre/select/__init__.py | 32 ++ .../espectre/select/espectre_select.cpp | 11 + .../espectre/select/espectre_select.h | 19 ++ .../components/espectre/sensor/__init__.py | 78 +++++ .../espectre/sensor/espectre_diagnostics.cpp | 40 +++ .../espectre/sensor/espectre_diagnostics.h | 32 ++ esphome/core/defines.h | 4 + esphome/idf_component.yml | 4 + tests/components/espectre/__init__.py | 24 ++ tests/components/espectre/common.yaml | 47 +++ tests/components/espectre/espectre_sdk.h | 105 ++++++ .../sensor/espectre_diagnostics_test.cpp | 28 ++ tests/components/espectre/test.esp32-ard.yaml | 2 + .../espectre/test.esp32-c3-idf.yaml | 8 + .../espectre/test.esp32-c5-idf.yaml | 5 + .../espectre/test.esp32-c6-idf.yaml | 8 + tests/components/espectre/test.esp32-idf.yaml | 5 + .../espectre/test.esp32-s2-idf.yaml | 6 + .../espectre/test.esp32-s3-ard.yaml | 5 + .../espectre/test.esp32-s3-idf.yaml | 7 + tests/components/espectre/test_espectre.cpp | 164 ++++++++++ .../components/espectre/__init__.py | 0 .../components/espectre/test_init.py | 118 +++++++ 30 files changed, 1424 insertions(+) create mode 100644 esphome/components/espectre/__init__.py create mode 100644 esphome/components/espectre/binary_sensor.py create mode 100644 esphome/components/espectre/button/__init__.py create mode 100644 esphome/components/espectre/button/espectre_button.h create mode 100644 esphome/components/espectre/espectre.cpp create mode 100644 esphome/components/espectre/espectre.h create mode 100644 esphome/components/espectre/select/__init__.py create mode 100644 esphome/components/espectre/select/espectre_select.cpp create mode 100644 esphome/components/espectre/select/espectre_select.h create mode 100644 esphome/components/espectre/sensor/__init__.py create mode 100644 esphome/components/espectre/sensor/espectre_diagnostics.cpp create mode 100644 esphome/components/espectre/sensor/espectre_diagnostics.h create mode 100644 tests/components/espectre/__init__.py create mode 100644 tests/components/espectre/common.yaml create mode 100644 tests/components/espectre/espectre_sdk.h create mode 100644 tests/components/espectre/sensor/espectre_diagnostics_test.cpp create mode 100644 tests/components/espectre/test.esp32-ard.yaml create mode 100644 tests/components/espectre/test.esp32-c3-idf.yaml create mode 100644 tests/components/espectre/test.esp32-c5-idf.yaml create mode 100644 tests/components/espectre/test.esp32-c6-idf.yaml create mode 100644 tests/components/espectre/test.esp32-idf.yaml create mode 100644 tests/components/espectre/test.esp32-s2-idf.yaml create mode 100644 tests/components/espectre/test.esp32-s3-ard.yaml create mode 100644 tests/components/espectre/test.esp32-s3-idf.yaml create mode 100644 tests/components/espectre/test_espectre.cpp create mode 100644 tests/unit_tests/components/espectre/__init__.py create mode 100644 tests/unit_tests/components/espectre/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 1695e633ef..930bdc08d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow +esphome/components/espectre/* @francescopace esphome/components/espnow/* @jesserockz esphome/components/espnow/packet_transport/* @EasilyBoredEngineer esphome/components/ethernet_info/* @gtjadsonsantos diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py new file mode 100644 index 0000000000..a46d6507fd --- /dev/null +++ b/esphome/components/espectre/__init__.py @@ -0,0 +1,185 @@ +from ipaddress import IPv4Address + +import esphome.codegen as cg +from esphome.components import esp32, wifi +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WIFI +from esphome.core import CORE +from esphome.types import ConfigType + +DOMAIN = "espectre" +CODEOWNERS = ["@francescopace"] +DEPENDENCIES = ["esp32", "wifi"] + +CONF_ESPECTRE_ID = "espectre_id" +CONF_DETECTION_ALGORITHM = "detection_algorithm" +CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile" +CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode" +CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip" +CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group" +CONF_MOTION_ON_HITS = "motion_on_hits" +CONF_MOTION_OFF_HITS = "motion_off_hits" + +# Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace. +espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre") +ESPectreComponent = espectre_ns.class_("ESPectreComponent", cg.Component) +sdk_ns = cg.global_ns.namespace("::espectre") +DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True) +CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) +TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) +WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) + +DETECTION_ALGORITHMS = { + "lightweight": DetectionAlgorithm.LIGHTWEIGHT, + "high_accuracy": DetectionAlgorithm.HIGH_ACCURACY, +} +CSI_CAPTURE_PROFILES = { + "auto": CsiCapturePolicy.AUTO, + "lltf": CsiCapturePolicy.LLTF, + "ht_vht": CsiCapturePolicy.HT_VHT, +} +TRAFFIC_GENERATOR_MODES = { + "ping": TrafficGeneratorMode.PING, + "dns": TrafficGeneratorMode.DNS, + "dns_tcp": TrafficGeneratorMode.DNS_TCP, + "wifi_raw": TrafficGeneratorMode.WIFI_RAW, + "external": TrafficGeneratorMode.EXTERNAL_HOST, +} + + +def validate_target_ip(value: str) -> str: + value = str(cv.ipv4address(value)) + first_octet = int(IPv4Address(value)) >> 24 + if first_octet in (0, 127) or first_octet >= 224 or value == "255.255.255.255": + raise cv.Invalid("ESPectre traffic target must be a unicast IPv4 address") + return value + + +def validate_multicast_group(value: str) -> str: + """An IPv4 multicast group, or an empty string to skip joining one.""" + if not (value := cv.string_strict(value).strip()): + return value + value = str(cv.ipv4address(value)) + if not IPv4Address(value).is_multicast: + raise cv.Invalid("ESPectre multicast group must be an IPv4 multicast address") + return value + + +def supported_traffic_generator_modes(config: ConfigType) -> list[str]: + """Traffic generator modes available with this chip and CSI capture profile.""" + wifi_raw = ( + esp32.get_esp32_variant() != VARIANT_ESP32C6 + and config[CONF_CSI_CAPTURE_PROFILE] != "ht_vht" + ) + return [mode for mode in TRAFFIC_GENERATOR_MODES if wifi_raw or mode != "wifi_raw"] + + +def validate_config(config: ConfigType) -> ConfigType: + mode = config[CONF_TRAFFIC_GENERATOR_MODE] + if mode not in supported_traffic_generator_modes(config): + raise cv.Invalid( + "wifi_raw traffic is not supported on ESP32-C6 " + "or with the ht_vht CSI capture profile" + ) + if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config: + raise cv.Invalid(f"{mode} traffic does not use a target IP address") + if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config: + raise cv.Invalid( + f"{CONF_CSI_TRAFFIC_MULTICAST_GROUP} requires " + f"{CONF_TRAFFIC_GENERATOR_MODE}: external" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESPectreComponent), + cv.Optional(CONF_DETECTION_ALGORITHM, default="lightweight"): cv.enum( + DETECTION_ALGORITHMS, lower=True + ), + cv.Optional(CONF_CSI_CAPTURE_PROFILE, default="auto"): cv.enum( + CSI_CAPTURE_PROFILES, lower=True + ), + cv.Optional(CONF_TRAFFIC_GENERATOR_MODE, default="ping"): cv.enum( + TRAFFIC_GENERATOR_MODES, lower=True + ), + cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip, + cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group, + cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20), + cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + ], + msg_prefix="ESPectre", + ), + # Arduino 3.3.7 is the first release built on ESP-IDF 5.5.3. + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 3), esp32_arduino=cv.Version(3, 3, 7) + ), + validate_config, +) + + +def final_validate(config: ConfigType) -> None: + wifi.force_power_save_off( + "ESPectre needs the radio awake to receive a steady flow of CSI packets" + ) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add_define("USE_ESPECTRE") + cg.add(var.set_detection_algorithm(config[CONF_DETECTION_ALGORITHM])) + cg.add(var.set_csi_capture_profile(config[CONF_CSI_CAPTURE_PROFILE])) + cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE])) + if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None: + cg.add(var.set_traffic_generator_target_ip(target_ip)) + if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None: + cg.add(var.set_csi_traffic_multicast_group(group)) + if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None: + cg.add(var.set_motion_on_hits(on_hits)) + if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None: + cg.add(var.set_motion_off_hits(off_hits)) + if esp32.get_esp32_variant() == VARIANT_ESP32C5: + band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO") + cg.add( + var.set_wifi_band_policy( + { + "2.4GHZ": WifiBandPolicy.BAND_2G, + "5GHZ": WifiBandPolicy.BAND_5G, + "AUTO": WifiBandPolicy.AUTO, + }[band] + ) + ) + + wifi.enable_runtime_roaming_suppression() + esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0") + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True) + # CSI is reported once per received transmission, so aggregation hides frames from sensing. + # Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32). + # Both may lower Wi-Fi throughput for the whole firmware. + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", False) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False) + # Keep the radio awake while disconnected too, matching force_power_save_off(). + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False) diff --git a/esphome/components/espectre/binary_sensor.py b/esphome/components/espectre/binary_sensor.py new file mode 100644 index 0000000000..c012f823cf --- /dev/null +++ b/esphome/components/espectre/binary_sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_MOTION, DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ESPECTRE_ID, ESPectreComponent + +DEPENDENCIES = ["espectre"] +CONF_CALIBRATING = "calibrating" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_MOTION): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_MOTION + ), + cv.Optional(CONF_CALIBRATING): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_MOTION, CONF_CALIBRATING), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_MOTION, hub.set_motion_binary_sensor) + await binary_sensors(CONF_CALIBRATING, hub.set_calibrating_binary_sensor) diff --git a/esphome/components/espectre/button/__init__.py b/esphome/components/espectre/button/__init__.py new file mode 100644 index 0000000000..c6a167324e --- /dev/null +++ b/esphome/components/espectre/button/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns + +DEPENDENCIES = ["espectre"] + +RecalibrateButton = espectre_ns.class_("RecalibrateButton", button.Button) + +CONFIG_SCHEMA = button.button_schema( + RecalibrateButton, entity_category=ENTITY_CATEGORY_CONFIG +).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)}) + + +async def to_code(config: ConfigType) -> None: + var = await button.new_button(config) + await cg.register_parented(var, config[CONF_ESPECTRE_ID]) diff --git a/esphome/components/espectre/button/espectre_button.h b/esphome/components/espectre/button/espectre_button.h new file mode 100644 index 0000000000..f89c42f8fe --- /dev/null +++ b/esphome/components/espectre/button/espectre_button.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/button/button.h" +#include "../espectre.h" + +namespace esphome::espectre { + +class RecalibrateButton final : public button::Button, public Parented { + protected: + void press_action() override { this->parent_->recalibrate(); } +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/espectre.cpp b/esphome/components/espectre/espectre.cpp new file mode 100644 index 0000000000..0cce6a3b94 --- /dev/null +++ b/esphome/components/espectre/espectre.cpp @@ -0,0 +1,305 @@ +#include "espectre.h" + +#ifdef USE_ESPECTRE + +#include +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome::espectre { + +static const char *const TAG = "espectre"; + +struct TrafficModeOption { + const char *name; + ::espectre::TrafficGeneratorMode mode; +}; +// Option names match the traffic_generator_mode YAML values. +static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = { + {"ping", ::espectre::TrafficGeneratorMode::PING}, + {"dns", ::espectre::TrafficGeneratorMode::DNS}, + {"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP}, + {"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW}, + {"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST}, +}; + +static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) { + for (const auto &option : TRAFFIC_MODE_OPTIONS) { + if (option.mode == mode) + return option.name; + } + return nullptr; +} + +static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) { + switch (profile) { + case ::espectre::CsiCapturePolicy::LLTF: + return LOG_STR_LITERAL("lltf"); + case ::espectre::CsiCapturePolicy::HT_VHT: + return LOG_STR_LITERAL("ht_vht"); + default: + return LOG_STR_LITERAL("auto"); + } +} + +#ifdef USE_SELECT +void ESPectreComponent::request_traffic_generator_mode(const char *name) { + for (const auto &option : TRAFFIC_MODE_OPTIONS) { + if (strcmp(option.name, name) == 0) { + this->pending_traffic_mode_ = option.mode; + return; + } + } +} +#endif + +static int log_level(::espectre::LogLevel level) { + switch (level) { + case ::espectre::LogLevel::ERROR: + return ESPHOME_LOG_LEVEL_ERROR; + case ::espectre::LogLevel::WARNING: + return ESPHOME_LOG_LEVEL_WARN; + case ::espectre::LogLevel::INFO: + return ESPHOME_LOG_LEVEL_INFO; + case ::espectre::LogLevel::DEBUG: + return ESPHOME_LOG_LEVEL_DEBUG; + default: + return ESPHOME_LOG_LEVEL_VERBOSE; + } +} + +void ESPectreComponent::setup() { + // ESPHome owns scan results, including scans requested by the SDK's CSI recovery. + this->runtime_.config().wifi_scan_results_managed_externally = true; +#ifdef USE_SELECT + this->restore_traffic_mode_(); +#endif + this->start_runtime_(); +} + +void ESPectreComponent::start_runtime_() { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // A sensor stays in one place, and each roaming scan takes the radio off-channel for + // seconds, emptying the CSI window. Losing the access point still reconnects normally. + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->request_roaming_suppression(); + this->roaming_suppressed_ = true; + } +#endif + ::espectre::set_log_sink({ + .context = nullptr, + .enabled = [](void *, ::espectre::LogLevel level, const char *) { return log_level(level) <= ESPHOME_LOG_LEVEL; }, + .write = [](void *, ::espectre::LogLevel level, const char *tag, int line, const char *format, + va_list args) { esp_log_vprintf_(log_level(level), tag, line, format, args); }, + }); + if (!this->runtime_.setup(this)) { + ESP_LOGE(TAG, "Runtime setup failed"); + this->stop_(); + this->schedule_restart_(); + return; + } + this->running_ = true; + this->status_clear_error(); +#ifdef USE_SELECT + this->publish_traffic_mode_(); +#endif +} + +void ESPectreComponent::schedule_restart_() { + // A fault can be transient, such as a Wi-Fi stall, so retry instead of failing for good. + this->runtime_fault_ = false; + this->status_set_error(LOG_STR("Runtime stopped")); + ESP_LOGW(TAG, "Restarting the runtime in %" PRIu32 " s", RESTART_DELAY_MS / 1000); + this->restart_pending_ = true; + this->restart_requested_ms_ = App.get_loop_component_start_time(); +} + +#ifdef USE_SELECT +void ESPectreComponent::restore_traffic_mode_() { + if (this->traffic_mode_select_ == nullptr) + return; + // Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select. + const auto yaml_mode = this->runtime_.config().traffic_generator_mode; + this->traffic_mode_pref_ = + this->traffic_mode_select_->make_entity_preference(static_cast(yaml_mode) + 1); + uint8_t saved; + if (!this->traffic_mode_pref_.load(&saved)) + return; + const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved); + const char *name = traffic_mode_name(mode); + if (name != nullptr && this->traffic_mode_select_->has_option(name)) + this->runtime_.config().traffic_generator_mode = mode; +} + +void ESPectreComponent::apply_pending_traffic_mode_() { + if (!this->pending_traffic_mode_.has_value()) + return; + const auto mode = *this->pending_traffic_mode_; + this->pending_traffic_mode_.reset(); + if (this->runtime_.set_traffic_generator_mode(mode)) { + const auto saved = static_cast(mode); + this->traffic_mode_pref_.save(&saved); + } else { + ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode)); + } + this->publish_traffic_mode_(); +} + +void ESPectreComponent::publish_traffic_mode_() { + if (this->traffic_mode_select_ == nullptr) + return; + const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode); + if (name != nullptr) + this->traffic_mode_select_->publish_state(name); +} +#endif + +void ESPectreComponent::loop() { + if (this->runtime_fault_) { + this->stop_(); + this->schedule_restart_(); + return; + } + if (!this->running_) { + // Without a backend this only reaps a traffic worker that outlived the last runtime. + this->runtime_.loop(); + if (this->restart_pending_ && + App.get_loop_component_start_time() - this->restart_requested_ms_ >= RESTART_DELAY_MS) { + this->restart_pending_ = false; + this->start_runtime_(); + } + return; + } + if (this->recalibrate_pending_) { + this->recalibrate_pending_ = false; + if (!this->runtime_.trigger_recalibration()) { + ESP_LOGW(TAG, "Recalibration is not available"); + } + } +#ifdef USE_SELECT + this->apply_pending_traffic_mode_(); +#endif + this->runtime_.loop(); + if (this->runtime_fault_) + return; + + // Read once after the SDK finishes dispatching callbacks, including readiness changes. + const auto &snapshot = this->runtime_.snapshot(); +#ifdef USE_BINARY_SENSOR + if (this->calibrating_binary_sensor_ != nullptr && + (!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) { + this->calibrating_state_ = snapshot.calibrating; + this->calibrating_published_ = true; + this->calibrating_binary_sensor_->publish_state(snapshot.calibrating); + } +#endif + if (!snapshot.ready_to_publish) { + if (this->ready_) + this->invalidate_sensing_(); + this->movement_pending_ = false; + return; + } + +#ifdef USE_BINARY_SENSOR + const bool motion = snapshot.motion_state == ::espectre::MotionState::MOTION; + if (this->motion_binary_sensor_ != nullptr && (!this->ready_ || this->motion_state_ != motion)) { + this->motion_state_ = motion; + this->motion_binary_sensor_->publish_state(motion); + } +#endif +#ifdef USE_SENSOR + if (this->movement_sensor_ != nullptr && (this->movement_pending_ || !this->ready_)) + this->movement_sensor_->publish_state(snapshot.movement_metric); +#endif + this->movement_pending_ = false; + this->ready_ = true; +} + +void ESPectreComponent::invalidate_sensing_() { +#ifdef USE_BINARY_SENSOR + if (this->motion_binary_sensor_ != nullptr) + this->motion_binary_sensor_->invalidate_state(); +#endif +#ifdef USE_SENSOR + if (this->movement_sensor_ != nullptr) + this->movement_sensor_->publish_state(NAN); +#endif + this->ready_ = false; +} + +void ESPectreComponent::on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) { + if (success) { + this->calibrated_ = true; + this->status_clear_warning(); + ESP_LOGI(TAG, "Calibration complete"); + return; + } + // After a successful calibration, a failed one keeps that calibrated threshold. + if (!this->calibrated_) + this->status_set_warning(LOG_STR("Calibration failed")); + ESP_LOGW(TAG, "Calibration failed; retaining the previous threshold"); +} + +void ESPectreComponent::on_runtime_fault(const char *message) { + ESP_LOGE(TAG, "Runtime fault: %s", message); + this->runtime_fault_ = true; +} + +void ESPectreComponent::stop_() { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + if (this->roaming_suppressed_ && wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->release_roaming_suppression(); + } +#endif + this->roaming_suppressed_ = false; + this->running_ = false; + // The next runtime starts from the default threshold and publishes its calibration again. + this->calibrated_ = false; + this->calibrating_published_ = false; + this->runtime_.shutdown(); + ::espectre::clear_log_sink(); + this->invalidate_sensing_(); +#ifdef USE_BINARY_SENSOR + if (this->calibrating_binary_sensor_ != nullptr) + this->calibrating_binary_sensor_->invalidate_state(); +#endif +} + +void ESPectreComponent::on_shutdown() { + this->restart_pending_ = false; + this->stop_(); +} + +void ESPectreComponent::dump_config() { + const auto &config = this->runtime_.config(); + ESP_LOGCONFIG(TAG, + "ESPectre:\n" + " Detection algorithm: %s\n" + " CSI capture profile: %s\n" + " Traffic generator mode: %s\n" + " Motion on/off hits: %u/%u", + config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT + ? LOG_STR_LITERAL("lightweight") + : LOG_STR_LITERAL("high_accuracy"), + csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode), + config.motion_on_hits, config.motion_off_hits); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Movement score", this->movement_sensor_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_); +#endif +} + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/espectre.h b/esphome/components/espectre/espectre.h new file mode 100644 index 0000000000..e526c80cb4 --- /dev/null +++ b/esphome/components/espectre/espectre.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/optional.h" +#include +#include +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#include "esphome/core/preferences.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +namespace esphome::espectre { + +class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + // Register the SDK's Wi-Fi event handlers after network setup, before station startup. + float get_setup_priority() const override { return setup_priority::WIFI + 1.0f; } + + void set_detection_algorithm(::espectre::DetectionAlgorithm value) { + this->runtime_.config().detection_algorithm = value; + } + void set_csi_capture_profile(::espectre::CsiCapturePolicy value) { + this->runtime_.config().csi_capture_policy = value; + } + void set_traffic_generator_mode(::espectre::TrafficGeneratorMode value) { + this->runtime_.config().traffic_generator_mode = value; + } + void set_traffic_generator_target_ip(const std::string &value) { + this->runtime_.config().traffic_generator_target_ip = value; + } + void set_csi_traffic_multicast_group(const std::string &value) { + this->runtime_.config().csi_traffic_multicast_group = value; + } + void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; } + void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; } + void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; } + // Queue controls so entity automations cannot re-enter the SDK from a listener callback. + void recalibrate() { this->recalibrate_pending_ = true; } +#ifdef USE_SELECT + void set_traffic_mode_select(select::Select *value) { this->traffic_mode_select_ = value; } + void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; } + /// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored. + void request_traffic_generator_mode(const char *name); +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(motion) + SUB_BINARY_SENSOR(calibrating) +#endif +#ifdef USE_SENSOR + SUB_SENSOR(movement) +#endif + /// Latest one-second runtime diagnostics sample, or nullptr before the runtime starts. + const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); } + + protected: + // Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate. + void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; } + void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override; + void on_runtime_fault(const char *message) override; + void start_runtime_(); + void schedule_restart_(); + void invalidate_sensing_(); + void stop_(); +#ifdef USE_SELECT + void restore_traffic_mode_(); + void apply_pending_traffic_mode_(); + void publish_traffic_mode_(); +#endif + + static constexpr uint32_t RESTART_DELAY_MS = 30000; + + ::espectre::RuntimeFrontendController runtime_; + uint32_t restart_requested_ms_{0}; +#ifdef USE_SELECT + select::Select *traffic_mode_select_{nullptr}; + ESPPreferenceObject traffic_mode_pref_; + optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_; +#endif + bool recalibrate_pending_{false}; + bool movement_pending_{false}; + bool ready_{false}; + bool motion_state_{false}; + bool calibrating_state_{false}; + bool calibrating_published_{false}; + bool runtime_fault_{false}; + bool running_{false}; + bool restart_pending_{false}; + bool calibrated_{false}; + bool roaming_suppressed_{false}; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/select/__init__.py b/esphome/components/espectre/select/__init__.py new file mode 100644 index 0000000000..8a4835e58b --- /dev/null +++ b/esphome/components/espectre/select/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_ESPECTRE_ID, + DOMAIN, + ESPectreComponent, + espectre_ns, + supported_traffic_generator_modes, +) + +DEPENDENCIES = ["espectre"] + +TrafficModeSelect = espectre_ns.class_( + "TrafficModeSelect", select.Select, cg.Parented.template(ESPectreComponent) +) + +CONFIG_SCHEMA = select.select_schema( + TrafficModeSelect, entity_category=ENTITY_CATEGORY_CONFIG +).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)}) + + +async def to_code(config: ConfigType) -> None: + options = supported_traffic_generator_modes(CORE.config[DOMAIN]) + var = await select.new_select(config, options=options) + await cg.register_parented(var, config[CONF_ESPECTRE_ID]) + parent = await cg.get_variable(config[CONF_ESPECTRE_ID]) + cg.add(parent.set_traffic_mode_select(var)) diff --git a/esphome/components/espectre/select/espectre_select.cpp b/esphome/components/espectre/select/espectre_select.cpp new file mode 100644 index 0000000000..9569b2955b --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.cpp @@ -0,0 +1,11 @@ +#include "espectre_select.h" + +#ifdef USE_ESPECTRE + +namespace esphome::espectre { + +void TrafficModeSelect::control(size_t index) { this->parent_->request_traffic_generator_mode(this->option_at(index)); } + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/select/espectre_select.h b/esphome/components/espectre/select/espectre_select.h new file mode 100644 index 0000000000..3d27cfabf9 --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/select/select.h" +#include "../espectre.h" + +namespace esphome::espectre { + +class TrafficModeSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/sensor/__init__.py b/esphome/components/espectre/sensor/__init__.py new file mode 100644 index 0000000000..c0efac6204 --- /dev/null +++ b/esphome/components/espectre/sensor/__init__.py @@ -0,0 +1,78 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns + +DEPENDENCIES = ["espectre"] + +CONF_MOVEMENT = "movement" +CONF_DIAGNOSTICS = "diagnostics" +CONF_GENERATOR_RATE = "generator_rate" +CONF_TRAFFIC_TX_RATE = "traffic_tx_rate" +CONF_TRAFFIC_RX_RATE = "traffic_rx_rate" +CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate" +CONF_CSI_OCCUPANCY = "csi_occupancy" +UNIT_PACKETS_PER_SECOND = "pps" + +DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent) + + +def _diagnostic_schema(unit: str, accuracy_decimals: int) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=unit, + accuracy_decimals=accuracy_decimals, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + + +DIAGNOSTIC_SENSORS = { + CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0), +} + +# Diagnostics are rarely watched, so they publish only on request unless an interval is set. +DIAGNOSTICS_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DiagnosticsUpdater), + **{cv.Optional(key): schema for key, schema in DIAGNOSTIC_SENSORS.items()}, + } + ).extend(cv.polling_component_schema("never")), + cv.has_at_least_one_key(*DIAGNOSTIC_SENSORS), +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_MOVEMENT): sensor.sensor_schema( + accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT + ), + cv.Optional(CONF_DIAGNOSTICS): DIAGNOSTICS_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_MOVEMENT, CONF_DIAGNOSTICS), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + await sensor.sub_sensors(config)(CONF_MOVEMENT, hub.set_movement_sensor) + if (diagnostics_config := config.get(CONF_DIAGNOSTICS)) is not None: + updater = cg.new_Pvariable(diagnostics_config[CONF_ID], hub) + await cg.register_component(updater, diagnostics_config) + diagnostic_sensors = sensor.sub_sensors(diagnostics_config) + for key in DIAGNOSTIC_SENSORS: + await diagnostic_sensors(key, getattr(updater, f"set_{key}_sensor")) diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.cpp b/esphome/components/espectre/sensor/espectre_diagnostics.cpp new file mode 100644 index 0000000000..0edc3c0728 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.cpp @@ -0,0 +1,40 @@ +#include "espectre_diagnostics.h" + +#ifdef USE_ESPECTRE + +#include +#include "esphome/core/log.h" + +namespace esphome::espectre { + +static const char *const TAG = "espectre.sensor"; + +static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample, + float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) { + if (sensor != nullptr) + sensor->publish_state(sample != nullptr ? sample->*field * scale : NAN); +} + +void DiagnosticsUpdater::update() { + using Sample = ::espectre::RuntimeDiagnosticsSample; + const auto *sample = this->parent_->diagnostics_sample(); + publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps); + publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps); + publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps); + publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps); + publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f); +} + +void DiagnosticsUpdater::dump_config() { + ESP_LOGCONFIG(TAG, "ESPectre diagnostics:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_); + LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_); + LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_); + LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_); + LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_); +} + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.h b/esphome/components/espectre/sensor/espectre_diagnostics.h new file mode 100644 index 0000000000..fde1c46113 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "../espectre.h" + +namespace esphome::espectre { + +/// Publishes all diagnostic sensors together from the same runtime sample. +class DiagnosticsUpdater final : public PollingComponent { + public: + explicit DiagnosticsUpdater(ESPectreComponent *parent) : parent_(parent) {} + void update() override; + void dump_config() override; + + SUB_SENSOR(generator_rate) + SUB_SENSOR(traffic_tx_rate) + SUB_SENSOR(traffic_rx_rate) + SUB_SENSOR(csi_accepted_rate) + SUB_SENSOR(csi_occupancy) + + protected: + ESPectreComponent *parent_; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index abd4a3b08d..1d35b2a67b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -505,6 +505,10 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#define USE_ESPECTRE +#endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #endif diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 58027cb33f..f532e26249 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -41,6 +41,10 @@ dependencies: version: 2.12.13 rules: - if: "target in [esp32h2, esp32p4]" + francescopace/espectre: + version: "3.0.0" + rules: + - if: "target in [esp32, esp32s2, esp32s3, esp32c3, esp32c5, esp32c6]" zorxx/multipart-parser: version: 1.0.1 espressif/lan867x: diff --git a/tests/components/espectre/__init__.py b/tests/components/espectre/__init__.py new file mode 100644 index 0000000000..5e0c2c776d --- /dev/null +++ b/tests/components/espectre/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real schema needs an ESP32 target and Wi-Fi; the host test build has neither. + manifest.dependencies = ["binary_sensor", "sensor", "button", "select"] + manifest.config_schema = cv.Schema({}) + manifest.final_validate_schema = None + + async def to_code_testing(config: ConfigType) -> None: + # Resolve to the test double copied next to the tests. + cg.add_build_flag(f"-I{CORE.relative_src_path('espectre')}") + cg.add_define("USE_ESPECTRE") + # No entity platforms are configured, so count them to emit USE_BINARY_SENSOR, + # USE_SENSOR and USE_SELECT for the entity code under test. + CORE.platform_counts["binary_sensor"] += 2 + CORE.platform_counts["sensor"] += 1 + CORE.platform_counts["select"] += 1 + + manifest.to_code = to_code_testing diff --git a/tests/components/espectre/common.yaml b/tests/components/espectre/common.yaml new file mode 100644 index 0000000000..5eb256f559 --- /dev/null +++ b/tests/components/espectre/common.yaml @@ -0,0 +1,47 @@ +wifi: + ssid: MySSID + password: password1 + +espectre: + id: espectre_hub + +binary_sensor: + - platform: espectre + espectre_id: espectre_hub + motion: + name: Motion + calibrating: + name: Calibrating + +sensor: + - platform: espectre + espectre_id: espectre_hub + movement: + name: Movement score + diagnostics: + id: espectre_diagnostics + update_interval: 10s + generator_rate: + name: Generator Rate + traffic_tx_rate: + name: Traffic TX Rate + traffic_rx_rate: + name: Traffic RX Rate + csi_accepted_rate: + name: CSI Accepted Rate + csi_occupancy: + name: CSI Temporal Occupancy + +select: + - platform: espectre + espectre_id: espectre_hub + name: CSI Traffic Source + +button: + - platform: espectre + espectre_id: espectre_hub + name: Recalibrate + - platform: template + name: Refresh Diagnostics + on_press: + - component.update: espectre_diagnostics diff --git a/tests/components/espectre/espectre_sdk.h b/tests/components/espectre/espectre_sdk.h new file mode 100644 index 0000000000..6a7949d88a --- /dev/null +++ b/tests/components/espectre/espectre_sdk.h @@ -0,0 +1,105 @@ +#pragma once + +// Lets the tests skip themselves when clang-tidy builds them against the real SDK. +#define ESPECTRE_SDK_TEST_DOUBLE + +// SDK test double: hardware behavior belongs to the SDK; these tests exercise the ESPHome adapter. +#include +#include +#include +#include + +namespace espectre { + +enum class DetectionAlgorithm { LIGHTWEIGHT, HIGH_ACCURACY }; +enum class CsiCapturePolicy { AUTO, LLTF, HT_VHT }; +enum class TrafficGeneratorMode { PING, DNS, DNS_TCP, WIFI_RAW, EXTERNAL_HOST }; +enum class WifiBandPolicy { BAND_2G, BAND_5G, AUTO }; +enum class MotionState { IDLE, MOTION }; +enum class LogLevel { ERROR, WARNING, INFO, DEBUG, VERBOSE }; + +constexpr float runtime_default_threshold(DetectionAlgorithm algorithm) { + return algorithm == DetectionAlgorithm::HIGH_ACCURACY ? 0.5f : 0.66218545f; +} + +struct RuntimeConfig { + DetectionAlgorithm detection_algorithm{DetectionAlgorithm::LIGHTWEIGHT}; + float threshold{runtime_default_threshold(DetectionAlgorithm::LIGHTWEIGHT)}; + CsiCapturePolicy csi_capture_policy{CsiCapturePolicy::AUTO}; + TrafficGeneratorMode traffic_generator_mode{TrafficGeneratorMode::PING}; + std::string traffic_generator_target_ip; + std::string csi_traffic_multicast_group{"239.255.0.1"}; + uint8_t motion_on_hits{4}; + uint8_t motion_off_hits{3}; + WifiBandPolicy wifi_band_policy{WifiBandPolicy::AUTO}; + bool wifi_scan_results_managed_externally{false}; + bool persist_runtime_overrides{false}; +}; +struct RuntimeDiagnosticsSample { + float generator_pps{0.0f}; + float traffic_tx_pps{0.0f}; + float traffic_rx_pps{0.0f}; + float csi_accepted_pps{0.0f}; + float csi_occupancy_ratio{0.0f}; +}; +struct RuntimeSnapshot { + bool ready_to_publish{false}; + bool calibrating{false}; + MotionState motion_state{MotionState::IDLE}; + float movement_metric{0.0f}; +}; +class IRuntimeListener { + public: + virtual ~IRuntimeListener() = default; + virtual void on_live_telemetry(const RuntimeSnapshot &snapshot) {} + virtual void on_calibration_finished(const RuntimeSnapshot &snapshot, bool success) {} + virtual void on_runtime_fault(const char *message) {} +}; +struct LogSink { + void *context; + bool (*enabled)(void *, LogLevel, const char *); + void (*write)(void *, LogLevel, const char *, int, const char *, va_list); +}; +inline bool set_log_sink(const LogSink &sink) { return true; } +inline void clear_log_sink() {} + +class RuntimeFrontendController { + public: + RuntimeFrontendController() { instance = this; } + RuntimeConfig &config() { return config_; } + const RuntimeSnapshot &snapshot() const { return snapshot_; } + const RuntimeDiagnosticsSample *diagnostics_sample() const { return &diagnostics_sample_; } + bool setup(IRuntimeListener *listener) { + setup_calls++; + this->listener = listener; + this->config_at_setup_ = this->config_; + return setup_result; + } + void loop() { + if (loop_hook) + loop_hook(); + } + bool trigger_recalibration() { + recalibration_calls++; + return true; + } + void shutdown() { shutdown_called = true; } + bool set_traffic_generator_mode(TrafficGeneratorMode mode) { + config_.traffic_generator_mode = mode; + return true; + } + + inline static RuntimeFrontendController *instance{nullptr}; + RuntimeConfig config_; + RuntimeConfig config_at_setup_; + RuntimeSnapshot snapshot_; + RuntimeDiagnosticsSample diagnostics_sample_; + IRuntimeListener *listener{nullptr}; + std::function loop_hook; + bool setup_result{true}; + bool shutdown_called{false}; + unsigned recalibration_calls{0}; + unsigned setup_calls{0}; +}; + +} // namespace espectre diff --git a/tests/components/espectre/sensor/espectre_diagnostics_test.cpp b/tests/components/espectre/sensor/espectre_diagnostics_test.cpp new file mode 100644 index 0000000000..1fea66ec96 --- /dev/null +++ b/tests/components/espectre/sensor/espectre_diagnostics_test.cpp @@ -0,0 +1,28 @@ +#include "gtest/gtest.h" +#include "esphome/components/espectre/sensor/espectre_diagnostics.h" + +#ifdef ESPECTRE_SDK_TEST_DOUBLE + +namespace esphome::espectre::testing { + +TEST(ESPectreDiagnosticsTest, PublishOnUpdate) { + ESPectreComponent component; + auto *sdk = ::espectre::RuntimeFrontendController::instance; + sensor::Sensor accepted; + sensor::Sensor occupancy; + DiagnosticsUpdater updater(&component); + updater.set_csi_accepted_rate_sensor(&accepted); + updater.set_csi_occupancy_sensor(&occupancy); + component.setup(); + sdk->diagnostics_sample_.csi_accepted_pps = 98.5f; + sdk->diagnostics_sample_.csi_occupancy_ratio = 0.9f; + component.loop(); + EXPECT_FALSE(accepted.has_state()); + updater.update(); + EXPECT_FLOAT_EQ(accepted.state, 98.5f); + EXPECT_FLOAT_EQ(occupancy.state, 90.0f); +} + +} // namespace esphome::espectre::testing + +#endif // ESPECTRE_SDK_TEST_DOUBLE diff --git a/tests/components/espectre/test.esp32-ard.yaml b/tests/components/espectre/test.esp32-ard.yaml new file mode 100644 index 0000000000..a5884e9a36 --- /dev/null +++ b/tests/components/espectre/test.esp32-ard.yaml @@ -0,0 +1,2 @@ +packages: + espectre: !include common.yaml diff --git a/tests/components/espectre/test.esp32-c3-idf.yaml b/tests/components/espectre/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..8bd934ac15 --- /dev/null +++ b/tests/components/espectre/test.esp32-c3-idf.yaml @@ -0,0 +1,8 @@ +packages: + espectre: !include common.yaml + +espectre: + detection_algorithm: high_accuracy + csi_capture_profile: lltf + traffic_generator_mode: dns + traffic_generator_target_ip: 192.168.1.1 diff --git a/tests/components/espectre/test.esp32-c5-idf.yaml b/tests/components/espectre/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..2bd791d206 --- /dev/null +++ b/tests/components/espectre/test.esp32-c5-idf.yaml @@ -0,0 +1,5 @@ +packages: + espectre: !include common.yaml + +espectre: + csi_capture_profile: lltf diff --git a/tests/components/espectre/test.esp32-c6-idf.yaml b/tests/components/espectre/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..cc93956dc0 --- /dev/null +++ b/tests/components/espectre/test.esp32-c6-idf.yaml @@ -0,0 +1,8 @@ +packages: + espectre: !include common.yaml + +espectre: + traffic_generator_mode: external + csi_traffic_multicast_group: 239.255.0.1 + motion_on_hits: 2 + motion_off_hits: 5 diff --git a/tests/components/espectre/test.esp32-idf.yaml b/tests/components/espectre/test.esp32-idf.yaml new file mode 100644 index 0000000000..2be67bf52a --- /dev/null +++ b/tests/components/espectre/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +wifi: + ssid: MySSID + password: password1 + +espectre: diff --git a/tests/components/espectre/test.esp32-s2-idf.yaml b/tests/components/espectre/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..2467109f32 --- /dev/null +++ b/tests/components/espectre/test.esp32-s2-idf.yaml @@ -0,0 +1,6 @@ +packages: + espectre: !include common.yaml + +espectre: + csi_capture_profile: lltf + traffic_generator_mode: wifi_raw diff --git a/tests/components/espectre/test.esp32-s3-ard.yaml b/tests/components/espectre/test.esp32-s3-ard.yaml new file mode 100644 index 0000000000..80f28da489 --- /dev/null +++ b/tests/components/espectre/test.esp32-s3-ard.yaml @@ -0,0 +1,5 @@ +packages: + espectre: !include common.yaml + +espectre: + traffic_generator_mode: external diff --git a/tests/components/espectre/test.esp32-s3-idf.yaml b/tests/components/espectre/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..8ddf4ab124 --- /dev/null +++ b/tests/components/espectre/test.esp32-s3-idf.yaml @@ -0,0 +1,7 @@ +packages: + espectre: !include common.yaml + +espectre: + detection_algorithm: high_accuracy + csi_capture_profile: ht_vht + traffic_generator_mode: dns_tcp diff --git a/tests/components/espectre/test_espectre.cpp b/tests/components/espectre/test_espectre.cpp new file mode 100644 index 0000000000..bca8bfe54d --- /dev/null +++ b/tests/components/espectre/test_espectre.cpp @@ -0,0 +1,164 @@ +#include +#include +#include "gtest/gtest.h" +#include "esphome/components/espectre/espectre.h" + +#ifdef ESPECTRE_SDK_TEST_DOUBLE + +namespace esphome::espectre::testing { + +class ESPectreTest : public ::testing::Test { + protected: + void SetUp() override { + component_.set_motion_binary_sensor(&motion_); + component_.set_calibrating_binary_sensor(&calibrating_); + component_.set_movement_sensor(&movement_); + } + + void make_ready_(bool motion = true) { + sdk_->snapshot_.ready_to_publish = true; + sdk_->snapshot_.motion_state = motion ? ::espectre::MotionState::MOTION : ::espectre::MotionState::IDLE; + sdk_->snapshot_.movement_metric = 0.8f; + component_.loop(); + } + + ESPectreComponent component_; + ::espectre::RuntimeFrontendController *sdk_{::espectre::RuntimeFrontendController::instance}; + binary_sensor::BinarySensor motion_; + binary_sensor::BinarySensor calibrating_; + sensor::Sensor movement_; +}; + +TEST_F(ESPectreTest, StartupDoesNotPublishFalseMotion) { + component_.setup(); + component_.loop(); + EXPECT_FALSE(motion_.has_state()); + EXPECT_FALSE(movement_.has_state()); + EXPECT_TRUE(calibrating_.has_state()); +} + +TEST_F(ESPectreTest, SetupHandsScanResultsAndPersistenceToEspHome) { + component_.setup(); + EXPECT_TRUE(sdk_->config_at_setup_.wifi_scan_results_managed_externally); + EXPECT_FALSE(sdk_->config_at_setup_.persist_runtime_overrides); +} + +TEST_F(ESPectreTest, ReadinessLossAndRecovery) { + component_.setup(); + std::vector> published_states; + motion_.add_full_state_callback( + [&published_states](optional, optional current) { published_states.push_back(current); }); + for (bool motion : {false, true}) { + published_states.clear(); + make_ready_(motion); + ASSERT_TRUE(motion_.has_state()); + EXPECT_EQ(motion_.state, motion); + EXPECT_FLOAT_EQ(movement_.state, 0.8f); + + sdk_->snapshot_.ready_to_publish = false; + sdk_->snapshot_.calibrating = true; + component_.loop(); + EXPECT_FALSE(motion_.has_state()); + EXPECT_TRUE(std::isnan(movement_.state)); + EXPECT_TRUE(calibrating_.state); + + sdk_->snapshot_.calibrating = false; + make_ready_(motion); + EXPECT_TRUE(motion_.has_state()); + EXPECT_EQ(motion_.state, motion); + EXPECT_FLOAT_EQ(movement_.state, 0.8f); + EXPECT_EQ(published_states, (std::vector>{motion, nullopt, motion})); + } +} + +TEST_F(ESPectreTest, TelemetryUsesFinalReadiness) { + component_.setup(); + make_ready_(); + std::vector published_states; + movement_.add_on_state_callback([&published_states](float state) { published_states.push_back(state); }); + sdk_->loop_hook = [this]() { + sdk_->listener->on_live_telemetry(sdk_->snapshot_); + sdk_->snapshot_.movement_metric = 0.9f; + sdk_->snapshot_.ready_to_publish = false; + }; + component_.loop(); + EXPECT_FALSE(motion_.has_state()); + EXPECT_TRUE(std::isnan(movement_.state)); + ASSERT_EQ(published_states.size(), 1u); + EXPECT_TRUE(std::isnan(published_states.front())); +} + +TEST_F(ESPectreTest, EntityCallbackQueuesRecalibration) { + component_.setup(); + movement_.add_on_state_callback([this](float) { component_.recalibrate(); }); + make_ready_(); + EXPECT_EQ(sdk_->recalibration_calls, 0u); + component_.loop(); + EXPECT_EQ(sdk_->recalibration_calls, 1u); +} + +TEST_F(ESPectreTest, CalibrationFailureWarnsOnlyBeforeFirstSuccess) { + component_.setup(); + sdk_->listener->on_calibration_finished(sdk_->snapshot_, false); + EXPECT_TRUE(component_.status_has_warning()); + EXPECT_FALSE(component_.is_failed()); + sdk_->listener->on_calibration_finished(sdk_->snapshot_, true); + EXPECT_FALSE(component_.status_has_warning()); + sdk_->listener->on_calibration_finished(sdk_->snapshot_, false); + EXPECT_FALSE(component_.status_has_warning()); +} + +TEST_F(ESPectreTest, RuntimeFaultStopsAndWaitsToRestart) { + component_.setup(); + make_ready_(); + sdk_->listener->on_runtime_fault("test fault"); + component_.loop(); + EXPECT_FALSE(component_.is_failed()); + EXPECT_TRUE(component_.status_has_error()); + EXPECT_TRUE(sdk_->shutdown_called); + EXPECT_FALSE(motion_.has_state()); + EXPECT_TRUE(std::isnan(movement_.state)); + + component_.loop(); + EXPECT_EQ(sdk_->setup_calls, 1u); +} + +TEST_F(ESPectreTest, FailedSetupStopsAndWaitsToRestart) { + sdk_->setup_result = false; + component_.setup(); + EXPECT_FALSE(component_.is_failed()); + EXPECT_TRUE(component_.status_has_error()); + EXPECT_TRUE(sdk_->shutdown_called); +} + +TEST_F(ESPectreTest, TrafficModeRequestAppliesInLoop) { + component_.setup(); + component_.request_traffic_generator_mode(::espectre::TrafficGeneratorMode::EXTERNAL_HOST); + EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::PING); + component_.loop(); + EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::EXTERNAL_HOST); +} + +TEST_F(ESPectreTest, TrafficModeRequestByName) { + component_.setup(); + component_.request_traffic_generator_mode("unknown"); + component_.loop(); + EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::PING); + component_.request_traffic_generator_mode("dns_tcp"); + component_.loop(); + EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::DNS_TCP); +} + +TEST_F(ESPectreTest, ShutdownInvalidatesEntities) { + component_.setup(); + make_ready_(); + component_.on_shutdown(); + EXPECT_TRUE(sdk_->shutdown_called); + EXPECT_FALSE(motion_.has_state()); + EXPECT_FALSE(calibrating_.has_state()); + EXPECT_TRUE(std::isnan(movement_.state)); +} + +} // namespace esphome::espectre::testing + +#endif // ESPECTRE_SDK_TEST_DOUBLE diff --git a/tests/unit_tests/components/espectre/__init__.py b/tests/unit_tests/components/espectre/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/espectre/test_init.py b/tests/unit_tests/components/espectre/test_init.py new file mode 100644 index 0000000000..a28adde103 --- /dev/null +++ b/tests/unit_tests/components/espectre/test_init.py @@ -0,0 +1,118 @@ +"""Validation tests for the ESPectre SDK adapter.""" + +import pytest + +from esphome.components import espectre +from esphome.components.wifi import POWER_SAVE_OFF_REASONS_KEY +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_ESP32, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, +) +from esphome.core import CORE +from esphome.types import ConfigType + + +@pytest.fixture(autouse=True) +def esp32_core() -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 3), + } + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + + +@pytest.mark.parametrize("variant", ["ESP32C2", "ESP32H2", "ESP32P4"]) +def test_unsupported_targets(variant: str) -> None: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + with pytest.raises(cv.Invalid, match="only available"): + espectre.CONFIG_SCHEMA({}) + + +def test_rejects_idf_below_minimum() -> None: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(5, 5, 2) + with pytest.raises(cv.Invalid, match="5.5.3"): + espectre.CONFIG_SCHEMA({}) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32S3"]) +def test_arduino_framework_version(variant: str) -> None: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 6) + with pytest.raises(cv.Invalid, match="3.3.7"): + espectre.CONFIG_SCHEMA({}) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 7) + espectre.CONFIG_SCHEMA({}) + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ( + {"traffic_generator_mode": "wifi_raw", "csi_capture_profile": "ht_vht"}, + "ht_vht CSI capture profile", + ), + ( + { + "traffic_generator_mode": "wifi_raw", + "traffic_generator_target_ip": "192.168.1.1", + }, + "does not use", + ), + ( + {"csi_traffic_multicast_group": "239.255.0.1"}, + "requires traffic_generator_mode: external", + ), + ], +) +def test_incompatible_traffic_options(config: ConfigType, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + espectre.CONFIG_SCHEMA(config) + + +def test_c6_raw_traffic() -> None: + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + with pytest.raises(cv.Invalid, match="not supported on ESP32-C6"): + espectre.CONFIG_SCHEMA({"traffic_generator_mode": "wifi_raw"}) + + +@pytest.mark.parametrize( + "address", + [ + "0.0.0.0", + "127.0.0.1", + "224.0.0.1", + "255.255.255.255", + ], +) +def test_invalid_traffic_targets(address: str) -> None: + with pytest.raises(cv.Invalid, match="unicast"): + espectre.CONFIG_SCHEMA({"traffic_generator_target_ip": address}) + + +def test_multicast_group_must_be_multicast() -> None: + with pytest.raises(cv.Invalid, match="multicast address"): + espectre.CONFIG_SCHEMA( + { + "traffic_generator_mode": "external", + "csi_traffic_multicast_group": "192.168.1.1", + } + ) + + +def test_empty_multicast_group_skips_joining() -> None: + config = espectre.CONFIG_SCHEMA( + {"traffic_generator_mode": "external", "csi_traffic_multicast_group": " "} + ) + assert config["csi_traffic_multicast_group"] == "" + + +def test_keeps_wifi_power_save_off() -> None: + espectre.final_validate({}) + assert any("ESPectre" in reason for reason in CORE.data[POWER_SAVE_OFF_REASONS_KEY])