[motion] Add gesture detection (#16787)

This commit is contained in:
Clyde Stubbs
2026-10-06 15:57:20 -05:00
committed by GitHub
parent a56c85949a
commit e68a499429
12 changed files with 1028 additions and 24 deletions
+48 -1
View File
@@ -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
+28 -1
View File
@@ -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_{};
+49 -12
View File
@@ -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))
+237 -10
View File
@@ -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
+29
View File
@@ -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
+141
View File
@@ -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
)