mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[motion] Add gesture detection (#16787)
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import re
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS
|
||||
from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS, CONF_UPDATE_INTERVAL
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import fnv1_hash_object_id
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
DOMAIN = "motion"
|
||||
@@ -37,6 +42,48 @@ SENSOR_SCHEMA = cv.Schema(
|
||||
|
||||
_AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE)
|
||||
|
||||
# Fast motion patterns (shakes, free-fall, sudden movement) need frequent samples
|
||||
# to be detected reliably; a slower parent update_interval makes them likely to be
|
||||
# missed between polls.
|
||||
MAX_RECOMMENDED_UPDATE_INTERVAL_MS = 100
|
||||
|
||||
|
||||
def get_motion_config(motion_id: ID) -> dict:
|
||||
"""Look up the (already-validated) config of the motion hub referenced by motion_id."""
|
||||
full_config = fv.full_config.get()
|
||||
motion_path = full_config.get_path_for_id(motion_id)[:-1]
|
||||
return full_config.get_config_for_path(motion_path)
|
||||
|
||||
|
||||
def check_update_interval(motion_id: ID, feature_name: str) -> None:
|
||||
"""Warn if the parent motion component polls too slowly for fast motion detection."""
|
||||
motion_config = get_motion_config(motion_id)
|
||||
update_interval = motion_config[CONF_UPDATE_INTERVAL]
|
||||
if update_interval.total_milliseconds > MAX_RECOMMENDED_UPDATE_INTERVAL_MS:
|
||||
_LOGGER.warning(
|
||||
"Motion component '%s' has update_interval %s, but %s detection "
|
||||
"works best with an update_interval of %dms or less.",
|
||||
motion_id,
|
||||
update_interval,
|
||||
feature_name,
|
||||
MAX_RECOMMENDED_UPDATE_INTERVAL_MS,
|
||||
)
|
||||
|
||||
|
||||
def check_has_accelerometer(
|
||||
motion_id: ID, feature_name: str, path: list[str] | None = None
|
||||
) -> None:
|
||||
"""Raise if the parent motion device does not measure acceleration.
|
||||
|
||||
`path` locates the error within the calling entity's config.
|
||||
"""
|
||||
motion_config = get_motion_config(motion_id)
|
||||
if not motion_config.get(KEY_ACCELEROMETER, False):
|
||||
raise cv.Invalid(
|
||||
f"The motion device does not measure acceleration, required for {feature_name}",
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
def _axis_map(config: dict) -> dict:
|
||||
errors = []
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DURATION, CONF_ID, CONF_THRESHOLD, CONF_TYPE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_MOTION_ID,
|
||||
MotionComponent,
|
||||
check_has_accelerometer,
|
||||
check_update_interval,
|
||||
motion_ns,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["motion"]
|
||||
|
||||
MotionBinarySensor = motion_ns.class_(
|
||||
"MotionBinarySensor", binary_sensor.BinarySensor, cg.Component
|
||||
)
|
||||
|
||||
MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType")
|
||||
|
||||
SENSOR_TYPES = {
|
||||
"face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP,
|
||||
"face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN,
|
||||
"free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL,
|
||||
"moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING,
|
||||
}
|
||||
|
||||
# face_up / face_down configure their threshold as a maximum tilt angle in degrees;
|
||||
# the C++ side compares against the cosine of that angle.
|
||||
ANGLE_THRESHOLD_TYPES = ("face_up", "face_down")
|
||||
|
||||
|
||||
def _binary_sensor_schema(
|
||||
default_threshold: float,
|
||||
threshold_validator: Callable[[Any], Any],
|
||||
default_duration: str | None = None,
|
||||
) -> cv.Schema:
|
||||
schema = (
|
||||
binary_sensor.binary_sensor_schema(MotionBinarySensor)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent),
|
||||
cv.Optional(
|
||||
CONF_THRESHOLD, default=default_threshold
|
||||
): threshold_validator,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
if default_duration is not None:
|
||||
schema = schema.extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_DURATION, default=default_duration
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
# Tilt angle in degrees, from horizontal, within which the device counts as face up/down.
|
||||
# 0 is excluded: cos(0) == 1.0 would make the C++ comparison always false, so
|
||||
# face_up/face_down would never trigger.
|
||||
_angle_threshold = cv.float_range(min=0.0, max=90.0, min_included=False)
|
||||
# 0 is excluded: free_fall would never trigger and moving would always be on.
|
||||
_positive_threshold = cv.float_range(min=0.0, min_included=False)
|
||||
|
||||
CONFIG_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
"face_up": _binary_sensor_schema(30.0, _angle_threshold),
|
||||
"face_down": _binary_sensor_schema(30.0, _angle_threshold),
|
||||
"free_fall": _binary_sensor_schema(0.15, _positive_threshold, "100ms"),
|
||||
"moving": _binary_sensor_schema(0.05, _positive_threshold, "2s"),
|
||||
}
|
||||
)
|
||||
|
||||
# These types detect brief motion events, so they need frequent samples;
|
||||
# face_up/face_down track a steady orientation and aren't time-sensitive.
|
||||
_FAST_DETECTION_TYPES = ("free_fall", "moving")
|
||||
|
||||
# face_up/face_down/free_fall are entirely accelerometer-driven; "moving" is exempt
|
||||
# since it detects motion from either the accelerometer or the gyroscope.
|
||||
_ACCEL_ONLY_TYPES = ("face_up", "face_down", "free_fall")
|
||||
|
||||
|
||||
def _final_validate(config: dict) -> None:
|
||||
sensor_type = config[CONF_TYPE]
|
||||
if sensor_type in _FAST_DETECTION_TYPES:
|
||||
check_update_interval(config[CONF_MOTION_ID], sensor_type.replace("_", "-"))
|
||||
if sensor_type in _ACCEL_ONLY_TYPES:
|
||||
check_has_accelerometer(
|
||||
config[CONF_MOTION_ID], sensor_type.replace("_", "-"), path=[CONF_TYPE]
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
sensor_type = config[CONF_TYPE]
|
||||
parent = await cg.get_variable(config[CONF_MOTION_ID])
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID], parent, SENSOR_TYPES[sensor_type])
|
||||
await binary_sensor.register_binary_sensor(var, config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
threshold = config[CONF_THRESHOLD]
|
||||
if sensor_type in ANGLE_THRESHOLD_TYPES:
|
||||
# Convert the configured tilt angle (degrees) to the cosine the C++ side expects.
|
||||
threshold = round(math.cos(math.radians(threshold)), 6)
|
||||
cg.add(var.set_threshold(threshold))
|
||||
if (duration := config.get(CONF_DURATION)) is not None:
|
||||
cg.add(var.set_duration(duration))
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "motion_binary_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
namespace esphome::motion {
|
||||
|
||||
static const char *const TAG = "motion.binary_sensor";
|
||||
|
||||
// Thresholds used to decide the device is at rest for face_up / face_down detection.
|
||||
// While moving (shaking, being picked up) the orientation reading is dominated by
|
||||
// linear acceleration and cannot be trusted, so those sensors block (hold) instead.
|
||||
static constexpr float STILL_ACCEL_TOLERANCE = 0.12f; // max deviation of |accel| from 1g, in g
|
||||
static constexpr float STILL_GYRO_THRESHOLD = 15.0f; // max angular rate magnitude, in °/s
|
||||
static constexpr float GYRO_THRESHOLD_SCALE = 50.0f; // arbitrary gyro threshold scale, in °/s per g of acceleration
|
||||
|
||||
MotionBinarySensor::MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type)
|
||||
: parent_(parent), type_(type) {}
|
||||
|
||||
bool MotionBinarySensor::is_stationary(const MotionData &data) {
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
if (std::isnan(ax) || std::isnan(ay) || std::isnan(az))
|
||||
return false;
|
||||
|
||||
// Total acceleration must be close to 1g; a larger deviation means the device is
|
||||
// being accelerated (shaken / moved) and the gravity direction cannot be trusted.
|
||||
float mag = std::sqrt(ax * ax + ay * ay + az * az);
|
||||
if (std::fabs(mag - 1.0f) > STILL_ACCEL_TOLERANCE)
|
||||
return false;
|
||||
|
||||
// If a gyroscope is present, also require the angular rate to be low.
|
||||
float gx = data.angular_rate[X_AXIS];
|
||||
float gy = data.angular_rate[Y_AXIS];
|
||||
float gz = data.angular_rate[Z_AXIS];
|
||||
if (!std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz)) {
|
||||
float gmag = std::sqrt(gx * gx + gy * gy + gz * gz);
|
||||
if (gmag > STILL_GYRO_THRESHOLD)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void MotionBinarySensor::setup() {
|
||||
this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); });
|
||||
this->publish_state(false); // default to false until the first update
|
||||
}
|
||||
|
||||
PROGMEM_STRING_TABLE(MotionBinarySensorTypeNames, "face_up", "face_down", "free_fall", "moving", "unknown");
|
||||
|
||||
void MotionBinarySensor::dump_config() {
|
||||
LOG_BINARY_SENSOR("", "Motion Binary Sensor", this);
|
||||
ESP_LOGCONFIG(
|
||||
TAG,
|
||||
" Type: %s\n"
|
||||
" Threshold: %.3f\n"
|
||||
" Duration: %" PRIu32 " ms",
|
||||
LOG_STR_ARG(MotionBinarySensorTypeNames::get_log_str(this->type_, MotionBinarySensorTypeNames::LAST_INDEX)),
|
||||
this->threshold_, this->duration_);
|
||||
}
|
||||
|
||||
void MotionBinarySensor::process_motion_data_(const MotionData &data) {
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
switch (this->type_) {
|
||||
case MOTION_BINARY_SENSOR_FACE_UP:
|
||||
case MOTION_BINARY_SENSOR_FACE_DOWN: {
|
||||
// Block while the device is moving: hold the last stable state instead of
|
||||
// reacting to transient acceleration spikes from shaking or handling.
|
||||
if (!is_stationary(data))
|
||||
break;
|
||||
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
float mag = std::sqrt(ax * ax + ay * ay + az * az);
|
||||
// is_stationary_() guarantees mag is close to 1g, so this is just a safety net.
|
||||
if (mag < 0.1f)
|
||||
break;
|
||||
|
||||
// threshold_ is the cosine of the maximum tilt: face_up / face_down are only
|
||||
// reported when the device is within that tilt of horizontal. Beyond it, both
|
||||
// sensors read false. Normalising by the magnitude makes the tilt limit
|
||||
// independent of any residual acceleration.
|
||||
float cos_tilt = az / mag;
|
||||
if (this->type_ == MOTION_BINARY_SENSOR_FACE_UP) {
|
||||
this->publish_state(cos_tilt > this->threshold_);
|
||||
} else {
|
||||
this->publish_state(cos_tilt < -this->threshold_);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MOTION_BINARY_SENSOR_FREE_FALL: {
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) {
|
||||
// Don't let a gap in valid data count towards the free-fall duration.
|
||||
this->free_fall_candidate_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
float mag = std::sqrt(ax * ax + ay * ay + az * az);
|
||||
|
||||
if (mag < this->threshold_) {
|
||||
if (!this->free_fall_candidate_) {
|
||||
this->free_fall_candidate_ = true;
|
||||
this->free_fall_start_time_ = now;
|
||||
} else if (now - this->free_fall_start_time_ >= this->duration_) {
|
||||
this->publish_state(true);
|
||||
}
|
||||
} else {
|
||||
this->free_fall_candidate_ = false;
|
||||
this->publish_state(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MOTION_BINARY_SENSOR_MOVING: {
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
float gx = data.angular_rate[X_AXIS];
|
||||
float gy = data.angular_rate[Y_AXIS];
|
||||
float gz = data.angular_rate[Z_AXIS];
|
||||
|
||||
bool moving = false;
|
||||
|
||||
// Check acceleration delta. Require all three axes to be valid so a NaN on any
|
||||
// axis can't poison last_accel_ and silently stop motion detection.
|
||||
bool accel_valid = !std::isnan(ax) && !std::isnan(ay) && !std::isnan(az);
|
||||
if (accel_valid) {
|
||||
if (!std::isnan(this->last_accel_[0])) {
|
||||
float dx = ax - this->last_accel_[0];
|
||||
float dy = ay - this->last_accel_[1];
|
||||
float dz = az - this->last_accel_[2];
|
||||
float accel_diff = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (accel_diff > this->threshold_) {
|
||||
moving = true;
|
||||
}
|
||||
}
|
||||
this->last_accel_[0] = ax;
|
||||
this->last_accel_[1] = ay;
|
||||
this->last_accel_[2] = az;
|
||||
}
|
||||
|
||||
// Check angular rate delta. Require all three axes to be valid for the same reason.
|
||||
bool gyro_valid = !std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz);
|
||||
if (gyro_valid) {
|
||||
if (!std::isnan(this->last_gyro_[0])) {
|
||||
float dgx = gx - this->last_gyro_[0];
|
||||
float dgy = gy - this->last_gyro_[1];
|
||||
float dgz = gz - this->last_gyro_[2];
|
||||
float gyro_diff = std::sqrt(dgx * dgx + dgy * dgy + dgz * dgz);
|
||||
if (gyro_diff > this->threshold_ * GYRO_THRESHOLD_SCALE) {
|
||||
moving = true;
|
||||
}
|
||||
}
|
||||
this->last_gyro_[0] = gx;
|
||||
this->last_gyro_[1] = gy;
|
||||
this->last_gyro_[2] = gz;
|
||||
}
|
||||
|
||||
// With no usable data this sample, don't assert "not moving" -- just wait for
|
||||
// the next one.
|
||||
if (!accel_valid && !gyro_valid)
|
||||
break;
|
||||
|
||||
if (moving) {
|
||||
this->publish_state(true);
|
||||
this->last_event_time_ = now;
|
||||
} else {
|
||||
if (this->state && (now - this->last_event_time_ >= this->duration_)) {
|
||||
this->publish_state(false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::motion
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "../motion_component.h"
|
||||
|
||||
namespace esphome::motion {
|
||||
|
||||
enum MotionBinarySensorType : uint8_t {
|
||||
MOTION_BINARY_SENSOR_FACE_UP = 0,
|
||||
MOTION_BINARY_SENSOR_FACE_DOWN,
|
||||
MOTION_BINARY_SENSOR_FREE_FALL,
|
||||
MOTION_BINARY_SENSOR_MOVING,
|
||||
};
|
||||
|
||||
class MotionBinarySensor : public Component, public binary_sensor::BinarySensor {
|
||||
public:
|
||||
explicit MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_threshold(float threshold) { this->threshold_ = threshold; }
|
||||
void set_duration(uint32_t duration) { this->duration_ = duration; }
|
||||
|
||||
protected:
|
||||
void process_motion_data_(const MotionData &data);
|
||||
|
||||
/// True when the device is at rest: total acceleration is close to 1g and (if a
|
||||
/// gyroscope is present) the angular rate is low. While not stationary the
|
||||
/// face_up / face_down orientation is unreliable, so their updates are suspended.
|
||||
static bool is_stationary(const MotionData &data);
|
||||
|
||||
MotionComponent *parent_;
|
||||
float threshold_{0.0f};
|
||||
uint32_t duration_{0};
|
||||
|
||||
// Tracking states
|
||||
uint32_t last_event_time_{0};
|
||||
uint32_t free_fall_start_time_{0};
|
||||
|
||||
// For derivative/variance tracking
|
||||
float last_accel_[3]{NAN, NAN, NAN};
|
||||
float last_gyro_[3]{NAN, NAN, NAN};
|
||||
|
||||
MotionBinarySensorType type_;
|
||||
bool free_fall_candidate_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::motion
|
||||
@@ -0,0 +1,55 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import event
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_THRESHOLD
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_MOTION_ID,
|
||||
MotionComponent,
|
||||
check_has_accelerometer,
|
||||
check_update_interval,
|
||||
motion_ns,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["motion"]
|
||||
|
||||
MotionEvent = motion_ns.class_("MotionEvent", event.Event, cg.Component)
|
||||
|
||||
EVENT_TYPES = ["shake"]
|
||||
|
||||
CONF_COOLDOWN = "cooldown"
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
event.event_schema(MotionEvent)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent),
|
||||
cv.Optional(CONF_THRESHOLD, default=0.5): cv.float_range(
|
||||
min=0.0, min_included=False
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_COOLDOWN, default="500ms"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: dict) -> None:
|
||||
check_update_interval(config[CONF_MOTION_ID], "shake")
|
||||
check_has_accelerometer(config[CONF_MOTION_ID], "shake")
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_MOTION_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], parent)
|
||||
await event.register_event(var, config, event_types=EVENT_TYPES)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_threshold(config[CONF_THRESHOLD]))
|
||||
cg.add(var.set_cooldown(config[CONF_COOLDOWN]))
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "motion_event.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::motion {
|
||||
|
||||
static const char *const TAG = "motion.event";
|
||||
|
||||
MotionEvent::MotionEvent(MotionComponent *parent) : parent_(parent) {}
|
||||
|
||||
void MotionEvent::setup() {
|
||||
this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); });
|
||||
}
|
||||
|
||||
void MotionEvent::dump_config() {
|
||||
LOG_EVENT("", "Motion Event", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Threshold: %.3f\n"
|
||||
" Cooldown: %" PRIu32 " ms",
|
||||
this->threshold_, this->cooldown_);
|
||||
}
|
||||
|
||||
void MotionEvent::process_motion_data_(const MotionData &data) {
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) {
|
||||
// Reset the baseline so the next valid sample doesn't jerk-compare across the gap.
|
||||
this->last_accel_[0] = NAN;
|
||||
this->last_accel_[1] = NAN;
|
||||
this->last_accel_[2] = NAN;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
if (!std::isnan(this->last_accel_[0])) {
|
||||
float dx = ax - this->last_accel_[0];
|
||||
float dy = ay - this->last_accel_[1];
|
||||
float dz = az - this->last_accel_[2];
|
||||
float jerk_mag = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
if (jerk_mag > this->threshold_) {
|
||||
if (now - this->last_trigger_time_ >= this->cooldown_) {
|
||||
this->trigger("shake");
|
||||
this->last_trigger_time_ = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->last_accel_[0] = ax;
|
||||
this->last_accel_[1] = ay;
|
||||
this->last_accel_[2] = az;
|
||||
}
|
||||
|
||||
} // namespace esphome::motion
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/event/event.h"
|
||||
#include "../motion_component.h"
|
||||
|
||||
namespace esphome::motion {
|
||||
|
||||
class MotionEvent : public Component, public event::Event {
|
||||
public:
|
||||
explicit MotionEvent(MotionComponent *parent);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_threshold(float threshold) { this->threshold_ = threshold; }
|
||||
void set_cooldown(uint32_t cooldown) { this->cooldown_ = cooldown; }
|
||||
|
||||
protected:
|
||||
void process_motion_data_(const MotionData &data);
|
||||
|
||||
MotionComponent *parent_;
|
||||
float threshold_{0.5f};
|
||||
uint32_t cooldown_{500};
|
||||
|
||||
// State tracking for shake detection
|
||||
uint32_t last_trigger_time_{0};
|
||||
float last_accel_[3]{NAN, NAN, NAN};
|
||||
};
|
||||
|
||||
} // namespace esphome::motion
|
||||
@@ -23,6 +23,33 @@ static constexpr uint8_t X_AXIS = 0;
|
||||
static constexpr uint8_t Y_AXIS = 1;
|
||||
static constexpr uint8_t Z_AXIS = 2;
|
||||
|
||||
/// Compute the device's in-plane orientation from the gravity vector.
|
||||
///
|
||||
/// Returns NAN when the device is flat (lying face up or face down), i.e. when the
|
||||
/// horizontal component of gravity, normalised by the total acceleration, is below
|
||||
/// `flat_threshold` (the sine of the minimum tilt angle). Otherwise returns the
|
||||
/// rotation snapped to the nearest of 0, 90, 180 or 270 degrees, derived from the
|
||||
/// direction of the horizontal gravity component.
|
||||
inline float orientation_degrees(const MotionData &data, float flat_threshold) {
|
||||
float ax = data.acceleration[X_AXIS];
|
||||
float ay = data.acceleration[Y_AXIS];
|
||||
float az = data.acceleration[Z_AXIS];
|
||||
if (std::isnan(ax) || std::isnan(ay) || std::isnan(az))
|
||||
return NAN;
|
||||
float mag = std::sqrt(ax * ax + ay * ay + az * az);
|
||||
if (mag < 0.1f)
|
||||
return NAN;
|
||||
// Horizontal component of gravity; near zero when the device lies flat.
|
||||
float h = std::sqrt(ax * ax + ay * ay);
|
||||
if (h / mag < flat_threshold)
|
||||
return NAN;
|
||||
// Direction of the horizontal component, snapped to the nearest 90°.
|
||||
float angle = std::atan2(ay, ax) * (180.0f / std::numbers::pi_v<float>);
|
||||
int quadrant = static_cast<int>(std::lround(angle / 90.0f));
|
||||
quadrant = ((quadrant % 4) + 4) % 4; // normalise to 0..3
|
||||
return quadrant * 90.0f;
|
||||
}
|
||||
|
||||
// Persisted calibration. `base_hash` ties the stored matrix to the build-time
|
||||
// (axis_map / transform_matrix) base; if the base changes the saved calibration
|
||||
// is ignored. Stored under a stable, ID-derived key so it overwrites in place.
|
||||
@@ -78,7 +105,7 @@ class MotionComponent : public PollingComponent {
|
||||
output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8];
|
||||
}
|
||||
|
||||
LazyCallbackManager<void(MotionData &)> motion_data_callback_{};
|
||||
LazyCallbackManager<void(MotionData const &)> motion_data_callback_{};
|
||||
uint32_t pref_key_{0};
|
||||
uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup()
|
||||
ESPPreferenceObject pref_{};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# YAML config keys
|
||||
import math
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
@@ -6,6 +8,7 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
ICON_ACCELERATION,
|
||||
ICON_ROTATE_RIGHT,
|
||||
ICON_SCREEN_ROTATION,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_DEGREE_PER_SECOND,
|
||||
UNIT_DEGREES,
|
||||
@@ -13,7 +16,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import std_ns
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
AXES,
|
||||
@@ -21,6 +24,7 @@ from . import (
|
||||
KEY_ACCELEROMETER,
|
||||
KEY_GYROSCOPE,
|
||||
SENSOR_SCHEMA,
|
||||
get_motion_config,
|
||||
motion_ns,
|
||||
)
|
||||
|
||||
@@ -28,10 +32,17 @@ MotionData = motion_ns.class_("MotionData")
|
||||
|
||||
CONF_PITCH = "pitch"
|
||||
CONF_ROLL = "roll"
|
||||
CONF_ORIENTATION = "orientation"
|
||||
CONF_FLAT_THRESHOLD = "flat_threshold"
|
||||
ICON_SEESAW = "mdi:seesaw"
|
||||
|
||||
# Minimum tilt angle (degrees) before the device is considered tilted enough to
|
||||
# report an orientation. Below this the device is treated as flat (sensor reports
|
||||
# NAN). Configured in degrees; converted to the sine of the angle for the C++ side.
|
||||
DEFAULT_FLAT_THRESHOLD = 30.0
|
||||
|
||||
def _accel_sensor_schema():
|
||||
|
||||
def _accel_sensor_schema() -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_G,
|
||||
icon=ICON_ACCELERATION,
|
||||
@@ -40,7 +51,7 @@ def _accel_sensor_schema():
|
||||
).extend(SENSOR_SCHEMA)
|
||||
|
||||
|
||||
def _gyro_sensor_schema():
|
||||
def _gyro_sensor_schema() -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DEGREE_PER_SECOND,
|
||||
icon=ICON_ROTATE_RIGHT,
|
||||
@@ -49,7 +60,7 @@ def _gyro_sensor_schema():
|
||||
).extend(SENSOR_SCHEMA)
|
||||
|
||||
|
||||
def _level_sensor_schema():
|
||||
def _level_sensor_schema() -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DEGREES,
|
||||
icon=ICON_SEESAW,
|
||||
@@ -58,6 +69,28 @@ def _level_sensor_schema():
|
||||
).extend(SENSOR_SCHEMA)
|
||||
|
||||
|
||||
def _orientation_sensor_schema() -> cv.Schema:
|
||||
# Reports a discrete rotation (0/90/180/270) or NAN when flat, so it is not a
|
||||
# continuous measurement (no state_class).
|
||||
return (
|
||||
sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DEGREES,
|
||||
icon=ICON_SCREEN_ROTATION,
|
||||
accuracy_decimals=0,
|
||||
)
|
||||
.extend(SENSOR_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
# 90 is excluded: sin(90) == 1.0 would make the C++ comparison always
|
||||
# true, so orientation would report NAN (flat) on every reading.
|
||||
cv.Optional(
|
||||
CONF_FLAT_THRESHOLD, default=DEFAULT_FLAT_THRESHOLD
|
||||
): cv.float_range(min=0.0, max=90.0, max_included=False),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_ACCELERATIONS = ["acceleration_" + a for a in AXES]
|
||||
_GYROSCOPES = ["gyroscope_" + g for g in AXES]
|
||||
_ANGULAR_RATES = ["angular_rate_" + r for r in AXES]
|
||||
@@ -68,20 +101,20 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
**{x: _gyro_sensor_schema() for x in _GYROSCOPES},
|
||||
**{x: _gyro_sensor_schema() for x in _ANGULAR_RATES},
|
||||
**{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)},
|
||||
CONF_ORIENTATION: _orientation_sensor_schema(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: dict) -> None:
|
||||
full_config = fv.full_config.get()
|
||||
motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1]
|
||||
motion_config = full_config.get_config_for_path(motion_path)
|
||||
motion_config = get_motion_config(config[CONF_MOTION_ID])
|
||||
has_accel = motion_config.get(KEY_ACCELEROMETER, False)
|
||||
has_gyro = motion_config.get(KEY_GYROSCOPE, False)
|
||||
|
||||
sensor_type = config[CONF_TYPE]
|
||||
if (
|
||||
sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH)
|
||||
sensor_type in _ACCELERATIONS
|
||||
or sensor_type in (CONF_ROLL, CONF_PITCH, CONF_ORIENTATION)
|
||||
) and not has_accel:
|
||||
raise cv.Invalid(
|
||||
"The motion device does not measure acceleration", path=[CONF_TYPE]
|
||||
@@ -95,11 +128,15 @@ def _final_validate(config: dict) -> None:
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj:
|
||||
def build_sensor_expr(sensor_type: str, data: MockObj, config: dict) -> MockObj:
|
||||
"""Build the C++ expression for a motion sensor type."""
|
||||
|
||||
# Note that <numbers> is included via this component's header file.
|
||||
pif = std_ns.namespace("numbers").pi_v.template(cg.float_)
|
||||
if sensor_type == CONF_ORIENTATION:
|
||||
# The C++ helper compares against the sine of the tilt angle.
|
||||
threshold = round(math.sin(math.radians(config[CONF_FLAT_THRESHOLD])), 6)
|
||||
return motion_ns.orientation_degrees(data, threshold)
|
||||
if sensor_type == CONF_ROLL:
|
||||
ay = data.acceleration[1]
|
||||
az = data.acceleration[2]
|
||||
@@ -115,14 +152,14 @@ def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj:
|
||||
return getattr(data, str(sensor_type[:-2]))[sensor_offset]
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
sensor_type = config[CONF_TYPE]
|
||||
var = await sensor.new_sensor(config)
|
||||
parent = await cg.get_variable(config[CONF_MOTION_ID])
|
||||
data = MockObj("data")
|
||||
expr = build_sensor_expr(sensor_type, data)
|
||||
expr = build_sensor_expr(sensor_type, data, config)
|
||||
value_lambda = await cg.process_lambda(
|
||||
var.publish_state(expr),
|
||||
[(MotionData.operator("ref"), str(data))],
|
||||
[(MotionData.operator("const").operator("ref"), str(data))],
|
||||
)
|
||||
cg.add(parent.add_listener(value_lambda))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -19,10 +20,20 @@ from esphome.components.motion import (
|
||||
_transform_matrix,
|
||||
_validate_matrix_options,
|
||||
)
|
||||
from esphome.components.motion.binary_sensor import (
|
||||
CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA,
|
||||
to_code as binary_sensor_to_code,
|
||||
)
|
||||
from esphome.components.motion.event import (
|
||||
CONFIG_SCHEMA as EVENT_CONFIG_SCHEMA,
|
||||
to_code as event_to_code,
|
||||
)
|
||||
from esphome.components.motion.sensor import (
|
||||
_ACCELERATIONS,
|
||||
_ANGULAR_RATES,
|
||||
_GYROSCOPES,
|
||||
CONF_FLAT_THRESHOLD,
|
||||
CONF_ORIENTATION,
|
||||
CONF_PITCH,
|
||||
CONF_ROLL,
|
||||
CONFIG_SCHEMA,
|
||||
@@ -263,7 +274,7 @@ class TestAxisMapToMatrix:
|
||||
|
||||
def _expr_str(sensor_type: str) -> str:
|
||||
"""Build a sensor expression via the production function and return its string form."""
|
||||
return str(build_sensor_expr(sensor_type, MockObj("data")))
|
||||
return str(build_sensor_expr(sensor_type, MockObj("data"), {}))
|
||||
|
||||
|
||||
class TestSensorExpressions:
|
||||
@@ -326,6 +337,19 @@ class TestSensorExpressions:
|
||||
# Pitch negates the x component
|
||||
assert "(-data.acceleration[0])" in expr
|
||||
|
||||
def test_orientation_expression_custom_threshold(self):
|
||||
"""The configured flat_threshold (degrees) is converted to a sine and passed
|
||||
to the helper."""
|
||||
|
||||
expr = str(
|
||||
build_sensor_expr(
|
||||
CONF_ORIENTATION, MockObj("data"), {CONF_FLAT_THRESHOLD: 45}
|
||||
)
|
||||
)
|
||||
assert "orientation_degrees(data" in expr
|
||||
expected = round(math.sin(math.radians(45)), 6) # 0.707107
|
||||
assert str(expected) in expr
|
||||
|
||||
|
||||
# --- Calibration math ---
|
||||
#
|
||||
@@ -372,7 +396,6 @@ def _calibrate_level(
|
||||
|
||||
Composes the correction with *matrix* (defaults to identity).
|
||||
"""
|
||||
import math
|
||||
|
||||
if matrix is None:
|
||||
matrix = list(IDENTITY)
|
||||
@@ -410,7 +433,6 @@ def _calibrate_level(
|
||||
|
||||
def _calibrate_heading(matrix: list[float], raw: list[float]) -> list[float]:
|
||||
"""Python port of MotionComponent::calibrate_heading."""
|
||||
import math
|
||||
|
||||
mapped = _mat_vec(matrix, raw)
|
||||
mx, my = mapped[0], mapped[1]
|
||||
@@ -438,7 +460,6 @@ class TestCalibrateLevel:
|
||||
|
||||
def _assert_maps_to_z(self, raw: list[float]) -> list[float]:
|
||||
"""Assert that the calibration matrix maps raw to [0, 0, 1]."""
|
||||
import math
|
||||
|
||||
m = _calibrate_level(raw)
|
||||
mag = math.sqrt(sum(v * v for v in raw))
|
||||
@@ -461,7 +482,6 @@ class TestCalibrateLevel:
|
||||
|
||||
def test_composes_with_existing_matrix(self):
|
||||
"""Level calibration should correct tilt while preserving an existing axis swap."""
|
||||
import math
|
||||
|
||||
swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y
|
||||
# Tilted raw: gravity has X component in raw frame
|
||||
@@ -495,7 +515,6 @@ class TestCalibrateLevel:
|
||||
self._assert_maps_to_z([0, 1.0, 0])
|
||||
|
||||
def test_tilted_45_degrees(self):
|
||||
import math
|
||||
|
||||
self._assert_maps_to_z(
|
||||
[math.sin(math.radians(45)), 0, math.cos(math.radians(45))]
|
||||
@@ -534,7 +553,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_y_axis_tilt_no_heading_error(self):
|
||||
"""Device tilted purely around Y — heading should already be correct."""
|
||||
import math
|
||||
|
||||
flat_raw = [0, 0, 1.0]
|
||||
level_m = _calibrate_level(flat_raw)
|
||||
@@ -547,7 +565,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_corrects_heading_rotation(self):
|
||||
"""After level+heading calibration, mapped Y should be ~0 when tilted."""
|
||||
import math
|
||||
|
||||
# Simulate a sensor whose chip is rotated 30° around Z relative to enclosure
|
||||
angle = math.radians(30)
|
||||
@@ -573,7 +590,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_full_calibration_sequence(self):
|
||||
"""End-to-end: level then heading produces correct frame alignment."""
|
||||
import math
|
||||
|
||||
# Chip is mounted tilted 15° around Y and 25° around Z
|
||||
# Build the chip-to-enclosure rotation: Rz(25°) * Ry(15°)
|
||||
@@ -846,7 +862,10 @@ class TestSensorConfigSchema:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sensor_type",
|
||||
_ACCELERATIONS + _GYROSCOPES + _ANGULAR_RATES + [CONF_PITCH, CONF_ROLL],
|
||||
_ACCELERATIONS
|
||||
+ _GYROSCOPES
|
||||
+ _ANGULAR_RATES
|
||||
+ [CONF_PITCH, CONF_ROLL, CONF_ORIENTATION],
|
||||
)
|
||||
def test_valid_types_accepted(self, sensor_type):
|
||||
"""Valid sensor types should pass type validation (errors from missing
|
||||
@@ -858,3 +877,211 @@ class TestSensorConfigSchema:
|
||||
assert "Unknown value" not in str(e), (
|
||||
f"Type '{sensor_type}' was rejected as unknown"
|
||||
)
|
||||
|
||||
|
||||
# --- Binary sensor & Event platform tests ---
|
||||
|
||||
|
||||
class TestBinarySensorSchema:
|
||||
def test_valid_types(self):
|
||||
res = BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "face_up",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "LSM6DS3 Face Up",
|
||||
}
|
||||
)
|
||||
assert res["type"] == "face_up"
|
||||
assert str(res["motion_id"]) == "my_motion_component"
|
||||
# face_up / face_down configure their threshold as a tilt angle in degrees.
|
||||
assert res["threshold"] == pytest.approx(30.0)
|
||||
|
||||
def test_free_fall_defaults(self):
|
||||
res = BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "free_fall",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "LSM6DS3 Free Fall",
|
||||
}
|
||||
)
|
||||
assert res["threshold"] == pytest.approx(0.15)
|
||||
assert res["duration"].total_milliseconds == 100
|
||||
|
||||
@pytest.mark.parametrize("sensor_type", ["free_fall", "moving"])
|
||||
def test_zero_threshold_rejected(self, sensor_type: str) -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": sensor_type,
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "Test",
|
||||
"threshold": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_type(self):
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "invalid_type",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "Test",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestEventSchema:
|
||||
def test_valid_event(self):
|
||||
res = EVENT_CONFIG_SCHEMA(
|
||||
{"motion_id": "my_motion_component", "name": "LSM6DS3 Shake"}
|
||||
)
|
||||
assert str(res["motion_id"]) == "my_motion_component"
|
||||
assert res["threshold"] == pytest.approx(0.5)
|
||||
assert res["cooldown"].total_milliseconds == 500
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_binary_sensor_codegen():
|
||||
mock_var = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.get_variable",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_parent,
|
||||
) as mock_get_var,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.new_Pvariable",
|
||||
return_value=mock_var,
|
||||
) as mock_new_pvar,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.binary_sensor.register_binary_sensor",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_bin,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.register_component",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_comp,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.add",
|
||||
) as mock_add,
|
||||
):
|
||||
yield {
|
||||
"get_variable": mock_get_var,
|
||||
"new_Pvariable": mock_new_pvar,
|
||||
"register_binary_sensor": mock_reg_bin,
|
||||
"register_component": mock_reg_comp,
|
||||
"add": mock_add,
|
||||
"var": mock_var,
|
||||
"parent": mock_parent,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_sensor_to_code(mock_binary_sensor_codegen):
|
||||
|
||||
# face_up has no duration: only the threshold is configured.
|
||||
config = {
|
||||
"id": "my_binary_sensor_id",
|
||||
"type": "face_up",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 30.0,
|
||||
}
|
||||
await binary_sensor_to_code(config)
|
||||
mock_binary_sensor_codegen["get_variable"].assert_called_once_with(
|
||||
"my_motion_component"
|
||||
)
|
||||
mock_binary_sensor_codegen["new_Pvariable"].assert_called_once()
|
||||
mock_binary_sensor_codegen["register_binary_sensor"].assert_called_once_with(
|
||||
mock_binary_sensor_codegen["var"], config
|
||||
)
|
||||
mock_binary_sensor_codegen["register_component"].assert_called_once_with(
|
||||
mock_binary_sensor_codegen["var"], config
|
||||
)
|
||||
assert mock_binary_sensor_codegen["add"].call_count == 1
|
||||
# face_up threshold is configured in degrees and converted to a cosine for C++.
|
||||
mock_binary_sensor_codegen["var"].set_threshold.assert_called_once_with(
|
||||
pytest.approx(math.cos(math.radians(30.0)))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_sensor_to_code_with_duration(mock_binary_sensor_codegen):
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
# free_fall has a duration: both threshold and duration are configured, and the
|
||||
# threshold is passed straight through (no degrees-to-cosine conversion).
|
||||
config = {
|
||||
"id": "my_binary_sensor_id",
|
||||
"type": "free_fall",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 0.15,
|
||||
"duration": TimePeriod(milliseconds=100),
|
||||
}
|
||||
await binary_sensor_to_code(config)
|
||||
assert mock_binary_sensor_codegen["add"].call_count == 2
|
||||
mock_binary_sensor_codegen["var"].set_threshold.assert_called_once_with(0.15)
|
||||
mock_binary_sensor_codegen["var"].set_duration.assert_called_once_with(
|
||||
TimePeriod(milliseconds=100)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_codegen():
|
||||
mock_var = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.get_variable",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_parent,
|
||||
) as mock_get_var,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.new_Pvariable",
|
||||
return_value=mock_var,
|
||||
) as mock_new_pvar,
|
||||
patch(
|
||||
"esphome.components.motion.event.event.register_event",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_event,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.register_component",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_comp,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.add",
|
||||
) as mock_add,
|
||||
):
|
||||
yield {
|
||||
"get_variable": mock_get_var,
|
||||
"new_Pvariable": mock_new_pvar,
|
||||
"register_event": mock_reg_event,
|
||||
"register_component": mock_reg_comp,
|
||||
"add": mock_add,
|
||||
"var": mock_var,
|
||||
"parent": mock_parent,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_to_code(mock_event_codegen):
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
config = {
|
||||
"id": "my_event_id",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 0.5,
|
||||
"cooldown": TimePeriod(milliseconds=500),
|
||||
}
|
||||
await event_to_code(config)
|
||||
mock_event_codegen["get_variable"].assert_called_once_with("my_motion_component")
|
||||
mock_event_codegen["new_Pvariable"].assert_called_once_with(
|
||||
"my_event_id", mock_event_codegen["parent"]
|
||||
)
|
||||
mock_event_codegen["register_event"].assert_called_once_with(
|
||||
mock_event_codegen["var"], config, event_types=["shake"]
|
||||
)
|
||||
mock_event_codegen["register_component"].assert_called_once_with(
|
||||
mock_event_codegen["var"], config
|
||||
)
|
||||
assert mock_event_codegen["add"].call_count == 2
|
||||
|
||||
@@ -57,11 +57,40 @@ sensor:
|
||||
motion_id: qmi8658_motion
|
||||
type: roll
|
||||
name: "Roll"
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
type: orientation
|
||||
name: "Orientation"
|
||||
flat_threshold: 30
|
||||
|
||||
binary_sensor:
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
type: face_up
|
||||
name: "Face Up"
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
type: face_down
|
||||
name: "Face Down"
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
type: free_fall
|
||||
name: "Free Fall"
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
type: moving
|
||||
name: "Moving"
|
||||
|
||||
event:
|
||||
- platform: motion
|
||||
motion_id: qmi8658_motion
|
||||
name: "Shake"
|
||||
|
||||
motion:
|
||||
- platform: qmi8658
|
||||
id: qmi8658_motion
|
||||
i2c_id: i2c_bus
|
||||
update_interval: 100ms
|
||||
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
|
||||
accelerometer_range: 4G
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for the motion platforms' update_interval warning (shake, free_fall, moving)."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config, yaml_util
|
||||
from esphome.core import CORE
|
||||
|
||||
CONFIG_TEMPLATE = """
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
i2c:
|
||||
sda: GPIO4
|
||||
scl: GPIO5
|
||||
|
||||
motion:
|
||||
- platform: lsm6ds
|
||||
id: lsm6ds_motion
|
||||
update_interval: {update_interval}
|
||||
|
||||
{platform_config}
|
||||
"""
|
||||
|
||||
EVENT_CONFIG = """
|
||||
event:
|
||||
- platform: motion
|
||||
motion_id: lsm6ds_motion
|
||||
name: "Shake event"
|
||||
"""
|
||||
|
||||
FREE_FALL_CONFIG = """
|
||||
binary_sensor:
|
||||
- platform: motion
|
||||
motion_id: lsm6ds_motion
|
||||
type: free_fall
|
||||
name: "Free fall"
|
||||
"""
|
||||
|
||||
MOVING_CONFIG = """
|
||||
binary_sensor:
|
||||
- platform: motion
|
||||
motion_id: lsm6ds_motion
|
||||
type: moving
|
||||
name: "Moving"
|
||||
"""
|
||||
|
||||
FACE_UP_CONFIG = """
|
||||
binary_sensor:
|
||||
- platform: motion
|
||||
motion_id: lsm6ds_motion
|
||||
type: face_up
|
||||
name: "Face up"
|
||||
"""
|
||||
|
||||
|
||||
def _read_config(tmp_path: Path, update_interval: str, platform_config: str):
|
||||
test_file = tmp_path / "test.yaml"
|
||||
test_file.write_text(
|
||||
CONFIG_TEMPLATE.format(
|
||||
update_interval=update_interval, platform_config=platform_config
|
||||
)
|
||||
)
|
||||
|
||||
parsed_yaml = yaml_util.load_yaml(test_file)
|
||||
|
||||
with (
|
||||
patch.object(yaml_util, "load_yaml", return_value=parsed_yaml),
|
||||
patch.object(CORE, "config_path", test_file),
|
||||
):
|
||||
return config.read_config({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_config", "feature_name"),
|
||||
[
|
||||
(EVENT_CONFIG, "shake"),
|
||||
(FREE_FALL_CONFIG, "free-fall"),
|
||||
(MOVING_CONFIG, "moving"),
|
||||
],
|
||||
ids=["shake", "free_fall", "moving"],
|
||||
)
|
||||
def test_warns_on_slow_update_interval(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
platform_config: str,
|
||||
feature_name: str,
|
||||
) -> None:
|
||||
"""A parent update_interval slower than 100ms should log a warning."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _read_config(tmp_path, "250ms", platform_config)
|
||||
|
||||
assert result is not None, "Slow update_interval should still be a valid config"
|
||||
warning_text = (
|
||||
f"{feature_name} detection works best with an update_interval of 100ms or less"
|
||||
)
|
||||
assert any(warning_text in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_config", "feature_name"),
|
||||
[
|
||||
(EVENT_CONFIG, "shake"),
|
||||
(FREE_FALL_CONFIG, "free-fall"),
|
||||
(MOVING_CONFIG, "moving"),
|
||||
],
|
||||
ids=["shake", "free_fall", "moving"],
|
||||
)
|
||||
def test_no_warning_on_fast_update_interval(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
platform_config: str,
|
||||
feature_name: str,
|
||||
) -> None:
|
||||
"""A parent update_interval of 100ms or less should not log a warning."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _read_config(tmp_path, "50ms", platform_config)
|
||||
|
||||
assert result is not None
|
||||
warning_text = f"{feature_name} detection works best with an update_interval"
|
||||
assert not any(warning_text in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_no_warning_for_face_up_with_slow_update_interval(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""face_up/face_down track steady orientation, not a fast event, so no warning."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _read_config(tmp_path, "250ms", FACE_UP_CONFIG)
|
||||
|
||||
assert result is not None
|
||||
assert not any(
|
||||
"detection works best with an update_interval" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
Reference in New Issue
Block a user