From e68a499429230dfd54ba9ac3905c661197dbd528 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:57:20 +1100 Subject: [PATCH] [motion] Add gesture detection (#16787) --- esphome/components/motion/__init__.py | 49 +++- .../motion/binary_sensor/__init__.py | 120 +++++++++ .../binary_sensor/motion_binary_sensor.cpp | 183 +++++++++++++ .../binary_sensor/motion_binary_sensor.h | 50 ++++ esphome/components/motion/event/__init__.py | 55 ++++ .../components/motion/event/motion_event.cpp | 57 ++++ .../components/motion/event/motion_event.h | 31 +++ esphome/components/motion/motion_component.h | 29 +- esphome/components/motion/sensor.py | 61 ++++- tests/component_tests/motion/test_motion.py | 247 +++++++++++++++++- tests/components/qmi8658/common.yaml | 29 ++ tests/unit_tests/components/test_motion.py | 141 ++++++++++ 12 files changed, 1028 insertions(+), 24 deletions(-) create mode 100644 esphome/components/motion/binary_sensor/__init__.py create mode 100644 esphome/components/motion/binary_sensor/motion_binary_sensor.cpp create mode 100644 esphome/components/motion/binary_sensor/motion_binary_sensor.h create mode 100644 esphome/components/motion/event/__init__.py create mode 100644 esphome/components/motion/event/motion_event.cpp create mode 100644 esphome/components/motion/event/motion_event.h create mode 100644 tests/unit_tests/components/test_motion.py diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index 63a104f1a7..741996cbb8 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -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 = [] diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py new file mode 100644 index 0000000000..0a3876c8ab --- /dev/null +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -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)) diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp new file mode 100644 index 0000000000..f663b41adb --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp @@ -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 diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.h b/esphome/components/motion/binary_sensor/motion_binary_sensor.h new file mode 100644 index 0000000000..6d556598d4 --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.h @@ -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 diff --git a/esphome/components/motion/event/__init__.py b/esphome/components/motion/event/__init__.py new file mode 100644 index 0000000000..9c72535f13 --- /dev/null +++ b/esphome/components/motion/event/__init__.py @@ -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])) diff --git a/esphome/components/motion/event/motion_event.cpp b/esphome/components/motion/event/motion_event.cpp new file mode 100644 index 0000000000..5568c953fd --- /dev/null +++ b/esphome/components/motion/event/motion_event.cpp @@ -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 diff --git a/esphome/components/motion/event/motion_event.h b/esphome/components/motion/event/motion_event.h new file mode 100644 index 0000000000..e8cd9f5352 --- /dev/null +++ b/esphome/components/motion/event/motion_event.h @@ -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 diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index d117ca0032..2ca920dbe0 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -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); + int quadrant = static_cast(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 motion_data_callback_{}; + LazyCallbackManager motion_data_callback_{}; uint32_t pref_key_{0}; uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() ESPPreferenceObject pref_{}; diff --git a/esphome/components/motion/sensor.py b/esphome/components/motion/sensor.py index ad3163a01a..db8a869236 100644 --- a/esphome/components/motion/sensor.py +++ b/esphome/components/motion/sensor.py @@ -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 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)) diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py index c502e06dab..ce0fd669b2 100644 --- a/tests/component_tests/motion/test_motion.py +++ b/tests/component_tests/motion/test_motion.py @@ -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 diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml index bfb9caff02..e20db427b8 100644 --- a/tests/components/qmi8658/common.yaml +++ b/tests/components/qmi8658/common.yaml @@ -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 diff --git a/tests/unit_tests/components/test_motion.py b/tests/unit_tests/components/test_motion.py new file mode 100644 index 0000000000..40fdb209fd --- /dev/null +++ b/tests/unit_tests/components/test_motion.py @@ -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 + )