mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 19:44:08 +00:00
[espectre] Add Wi-Fi CSI motion detection component (#19801)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user