[espectre] Add Wi-Fi CSI motion detection component (#19801)

This commit is contained in:
Francesco Pace
2026-10-06 19:46:06 -05:00
committed by GitHub
parent 19dce8840d
commit 923700dcc9
30 changed files with 1424 additions and 0 deletions
+1
View File
@@ -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
+185
View File
@@ -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)
@@ -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)
@@ -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])
@@ -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<ESPectreComponent> {
protected:
void press_action() override { this->parent_->recalibrate(); }
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
+305
View File
@@ -0,0 +1,305 @@
#include "espectre.h"
#ifdef USE_ESPECTRE
#include <cinttypes>
#include <cmath>
#include <cstring>
#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<uint8_t>(static_cast<uint32_t>(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<uint8_t>(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
+110
View File
@@ -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 <espectre_sdk.h>
#include <string>
#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
@@ -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))
@@ -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
@@ -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<ESPectreComponent> {
protected:
void control(size_t index) override;
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
@@ -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"))
@@ -0,0 +1,40 @@
#include "espectre_diagnostics.h"
#ifdef USE_ESPECTRE
#include <cmath>
#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
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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:
+24
View File
@@ -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 <espectre_sdk.h> 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
+47
View File
@@ -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
+105
View File
@@ -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 <cstdarg>
#include <cstdint>
#include <functional>
#include <string>
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<void()> loop_hook;
bool setup_result{true};
bool shutdown_called{false};
unsigned recalibration_calls{0};
unsigned setup_calls{0};
};
} // namespace espectre
@@ -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
@@ -0,0 +1,2 @@
packages:
espectre: !include common.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
@@ -0,0 +1,5 @@
packages:
espectre: !include common.yaml
espectre:
csi_capture_profile: lltf
@@ -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
@@ -0,0 +1,5 @@
wifi:
ssid: MySSID
password: password1
espectre:
@@ -0,0 +1,6 @@
packages:
espectre: !include common.yaml
espectre:
csi_capture_profile: lltf
traffic_generator_mode: wifi_raw
@@ -0,0 +1,5 @@
packages:
espectre: !include common.yaml
espectre:
traffic_generator_mode: external
@@ -0,0 +1,7 @@
packages:
espectre: !include common.yaml
espectre:
detection_algorithm: high_accuracy
csi_capture_profile: ht_vht
traffic_generator_mode: dns_tcp
+164
View File
@@ -0,0 +1,164 @@
#include <cmath>
#include <vector>
#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<optional<bool>> published_states;
motion_.add_full_state_callback(
[&published_states](optional<bool>, optional<bool> 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<optional<bool>>{motion, nullopt, motion}));
}
}
TEST_F(ESPectreTest, TelemetryUsesFinalReadiness) {
component_.setup();
make_ready_();
std::vector<float> 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
@@ -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])