mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[lis3dh] Add LIS3DH accelerometer component (#17393)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
Claude
pre-commit-ci-lite[bot]
J. Nick Koston
parent
93183c661e
commit
584d4878eb
@@ -320,6 +320,7 @@ esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lis2dw12/* @latonita
|
||||
esphome/components/lis2dw12_i2c/* @latonita
|
||||
esphome/components/lis2dw12_spi/* @latonita
|
||||
esphome/components/lis3dh/* @zebble
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.motion import MotionComponent
|
||||
|
||||
CODEOWNERS = ["@zebble"]
|
||||
DEPENDENCIES = ["i2c", "motion"]
|
||||
DOMAIN = "lis3dh"
|
||||
|
||||
CONF_LIS3DH_ID = "lis3dh_id"
|
||||
|
||||
# C++ namespace / class
|
||||
lis3dh_ns = cg.esphome_ns.namespace("lis3dh")
|
||||
LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice)
|
||||
|
||||
CONFIG_SCHEMA = {}
|
||||
@@ -0,0 +1,195 @@
|
||||
#include "lis3dh.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::lis3dh {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "lis3dh");
|
||||
|
||||
// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every
|
||||
// operating mode, since the chip leaves the unused low bits at zero.
|
||||
static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f};
|
||||
|
||||
static const LogString *mode_to_str(LIS3DHOperatingMode mode) {
|
||||
switch (mode) {
|
||||
case LIS3DH_MODE_LOW_POWER:
|
||||
return LOG_STR("low power (8-bit)");
|
||||
case LIS3DH_MODE_NORMAL:
|
||||
return LOG_STR("normal (10-bit)");
|
||||
default:
|
||||
return LOG_STR("high resolution (12-bit)");
|
||||
}
|
||||
}
|
||||
|
||||
// Output data rate in Hz, indexed by the ODR field minus one.
|
||||
static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344};
|
||||
|
||||
void LIS3DHComponent::setup() {
|
||||
MotionComponent::setup();
|
||||
|
||||
uint8_t who_am_i = 0;
|
||||
if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) {
|
||||
ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
if (who_am_i != LIS3DH_WHO_AM_I_VALUE) {
|
||||
ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE);
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) {
|
||||
ESP_LOGE(TAG, "Failed to configure sensor");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool LIS3DHComponent::setup_accelerometer_() {
|
||||
// CTRL_REG1: output data rate, low-power flag and axis enable.
|
||||
uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN;
|
||||
if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER)
|
||||
ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN;
|
||||
if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1))
|
||||
return false;
|
||||
|
||||
// CTRL_REG4: block data update, full-scale range and high-resolution flag.
|
||||
uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4);
|
||||
if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION)
|
||||
ctrl_reg4 |= LIS3DH_CTRL_REG4_HR;
|
||||
return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4);
|
||||
}
|
||||
|
||||
bool LIS3DHComponent::setup_temperature_() {
|
||||
// Only power the ADC / temperature sensor when a temperature sensor is used.
|
||||
uint8_t temp_cfg =
|
||||
this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN);
|
||||
return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg);
|
||||
}
|
||||
|
||||
bool LIS3DHComponent::setup_interrupt_() {
|
||||
// Runs even with no interrupt configured: the chip usually stays powered across resets,
|
||||
// so this drops whatever the previous firmware left in the interrupt registers.
|
||||
// High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed
|
||||
// from the threshold comparison. The output data registers stay unfiltered
|
||||
// (FDS = 0), so acceleration / pitch / roll still report the full gravity vector.
|
||||
uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00;
|
||||
if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2))
|
||||
return false;
|
||||
|
||||
if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) ||
|
||||
!this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) ||
|
||||
!this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_))
|
||||
return false;
|
||||
|
||||
// Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read.
|
||||
uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0;
|
||||
if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5))
|
||||
return false;
|
||||
|
||||
// Route the IA1 engine to the chosen physical pad and set the pad polarity.
|
||||
uint8_t ctrl_reg3 = 0;
|
||||
uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY;
|
||||
if (this->interrupt_axes_cfg_ != 0) {
|
||||
if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) {
|
||||
ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1;
|
||||
} else {
|
||||
ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1;
|
||||
}
|
||||
}
|
||||
if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6))
|
||||
return false;
|
||||
if (this->interrupt_axes_cfg_ == 0)
|
||||
return true;
|
||||
|
||||
// With the high-pass filter enabled, reading REFERENCE captures the current
|
||||
// acceleration as the filter's zero point (removing the standing gravity bias).
|
||||
if (this->interrupt_high_pass_) {
|
||||
uint8_t reference = 0;
|
||||
this->read_byte(LIS3DH_REG_REFERENCE, &reference);
|
||||
}
|
||||
|
||||
// Clear any latched request left over from the motion that woke the device.
|
||||
uint8_t src = 0;
|
||||
this->read_byte(LIS3DH_REG_INT1_SRC, &src);
|
||||
return true;
|
||||
}
|
||||
|
||||
void LIS3DHComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "LIS3DH:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Range: ±%ug\n"
|
||||
" Operating mode: %s\n"
|
||||
" Data rate: %uHz",
|
||||
2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)),
|
||||
(unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1]));
|
||||
if (this->interrupt_axes_cfg_ != 0) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Motion interrupt: %s (%s%s%s)\n"
|
||||
" Threshold: %u steps\n"
|
||||
" Duration: %u samples",
|
||||
this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"),
|
||||
this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"),
|
||||
this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""),
|
||||
this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""),
|
||||
this->interrupt_threshold_, this->interrupt_duration_);
|
||||
}
|
||||
MotionComponent::dump_config();
|
||||
}
|
||||
|
||||
void LIS3DHComponent::on_shutdown() {
|
||||
// Clear a latched request from motion seen while awake, so it does not wake the device straight away.
|
||||
if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) {
|
||||
uint8_t src = 0;
|
||||
this->read_byte(LIS3DH_REG_INT1_SRC, &src);
|
||||
}
|
||||
}
|
||||
|
||||
bool LIS3DHComponent::update_data(motion::MotionData &data) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
|
||||
uint8_t raw[6];
|
||||
if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) {
|
||||
this->status_set_warning(LOG_STR("Failed to read acceleration data"));
|
||||
return false;
|
||||
}
|
||||
|
||||
float scale;
|
||||
progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale));
|
||||
|
||||
// Data is little-endian and left-justified in a signed 16-bit register.
|
||||
int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]);
|
||||
int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]);
|
||||
int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]);
|
||||
ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z);
|
||||
data.acceleration[motion::X_AXIS] = raw_x * scale;
|
||||
data.acceleration[motion::Y_AXIS] = raw_y * scale;
|
||||
data.acceleration[motion::Z_AXIS] = raw_z * scale;
|
||||
|
||||
if (this->temperature_callback_.empty() || this->publish_temperature_())
|
||||
this->status_clear_warning();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LIS3DHComponent::publish_temperature_() {
|
||||
uint8_t raw[2];
|
||||
if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) {
|
||||
this->status_set_warning(LOG_STR("Failed to read temperature data"));
|
||||
return false;
|
||||
}
|
||||
// Signed, left-justified, relative value with 1 °C per count of the high byte; the
|
||||
// low byte carries the fractional bits in the modes that have them.
|
||||
float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C;
|
||||
this->temperature_callback_.call(temperature);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::lis3dh
|
||||
@@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/motion/motion_component.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::lis3dh {
|
||||
|
||||
// Register map (see ST datasheet DocID18198)
|
||||
static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte
|
||||
static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F;
|
||||
static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F;
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching
|
||||
static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity
|
||||
static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference
|
||||
static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment)
|
||||
static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30;
|
||||
static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31;
|
||||
static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32;
|
||||
static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33;
|
||||
|
||||
static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33;
|
||||
|
||||
// Sub-address auto-increment bit: OR into the register for multi-byte reads.
|
||||
static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80;
|
||||
|
||||
// CTRL_REG1
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable
|
||||
// CTRL_REG2
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt
|
||||
// CTRL_REG4
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update
|
||||
// CTRL_REG5
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt
|
||||
// CTRL_REG3 / CTRL_REG6 interrupt routing
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad
|
||||
static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low
|
||||
// TEMP_CFG_REG
|
||||
static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80;
|
||||
static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40;
|
||||
|
||||
// The temperature sensor is uncalibrated and reports relative changes only,
|
||||
// so this fixed reference is just a rough starting point for the °C output.
|
||||
static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f;
|
||||
|
||||
// Full-scale range: value is the index into the scale tables and the
|
||||
// FS field of CTRL_REG4 (shifted left by 4).
|
||||
enum LIS3DHRange : uint8_t {
|
||||
LIS3DH_RANGE_2G = 0,
|
||||
LIS3DH_RANGE_4G = 1,
|
||||
LIS3DH_RANGE_8G = 2,
|
||||
LIS3DH_RANGE_16G = 3,
|
||||
};
|
||||
|
||||
// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4).
|
||||
enum LIS3DHDataRate : uint8_t {
|
||||
LIS3DH_DATA_RATE_1HZ = 0x1,
|
||||
LIS3DH_DATA_RATE_10HZ = 0x2,
|
||||
LIS3DH_DATA_RATE_25HZ = 0x3,
|
||||
LIS3DH_DATA_RATE_50HZ = 0x4,
|
||||
LIS3DH_DATA_RATE_100HZ = 0x5,
|
||||
LIS3DH_DATA_RATE_200HZ = 0x6,
|
||||
LIS3DH_DATA_RATE_400HZ = 0x7,
|
||||
LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only
|
||||
LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power
|
||||
};
|
||||
|
||||
// Operating mode: selects the resolution (and thus power usage).
|
||||
enum LIS3DHOperatingMode : uint8_t {
|
||||
LIS3DH_MODE_LOW_POWER = 0, // 8-bit
|
||||
LIS3DH_MODE_NORMAL = 1, // 10-bit
|
||||
LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit
|
||||
};
|
||||
|
||||
// Physical interrupt pad the motion interrupt is routed to.
|
||||
enum LIS3DHInterruptPin : uint8_t {
|
||||
LIS3DH_INT_PIN_INT1 = 0,
|
||||
LIS3DH_INT_PIN_INT2 = 1,
|
||||
};
|
||||
|
||||
class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
|
||||
void set_range(LIS3DHRange range) { this->range_ = range; }
|
||||
void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; }
|
||||
void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; }
|
||||
// threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python.
|
||||
void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched,
|
||||
bool active_high, bool high_pass) {
|
||||
this->interrupt_pin_ = pin;
|
||||
this->interrupt_threshold_ = threshold;
|
||||
this->interrupt_duration_ = duration;
|
||||
this->interrupt_axes_cfg_ = axes_cfg;
|
||||
this->interrupt_latched_ = latched;
|
||||
this->interrupt_active_high_ = active_high;
|
||||
this->interrupt_high_pass_ = high_pass;
|
||||
}
|
||||
|
||||
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
|
||||
|
||||
protected:
|
||||
bool update_data(motion::MotionData &data) override;
|
||||
bool setup_accelerometer_();
|
||||
bool setup_temperature_();
|
||||
bool setup_interrupt_();
|
||||
bool publish_temperature_();
|
||||
|
||||
LIS3DHRange range_{LIS3DH_RANGE_2G};
|
||||
LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ};
|
||||
LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION};
|
||||
|
||||
// Motion (activity) interrupt configuration; a zero axis mask means disabled.
|
||||
LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1};
|
||||
uint8_t interrupt_threshold_{0};
|
||||
uint8_t interrupt_duration_{0};
|
||||
uint8_t interrupt_axes_cfg_{0};
|
||||
bool interrupt_latched_{false};
|
||||
bool interrupt_active_high_{true};
|
||||
bool interrupt_high_pass_{false};
|
||||
|
||||
LazyCallbackManager<void(float)> temperature_callback_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::lis3dh
|
||||
@@ -0,0 +1,214 @@
|
||||
import logging
|
||||
import math
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE
|
||||
from esphome.components.motion import AXES, motion_schema, new_motion_component
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import LIS3DHComponent, lis3dh_ns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_OPERATING_MODE = "operating_mode"
|
||||
CONF_AXES = "axes"
|
||||
CONF_LATCHED = "latched"
|
||||
CONF_ACTIVE_HIGH = "active_high"
|
||||
CONF_HIGH_PASS_FILTER = "high_pass_filter"
|
||||
|
||||
# Enum values must match the C++ enums.
|
||||
LIS3DHRange = lis3dh_ns.enum("LIS3DHRange")
|
||||
RANGE_OPTIONS = {
|
||||
"2G": LIS3DHRange.LIS3DH_RANGE_2G,
|
||||
"4G": LIS3DHRange.LIS3DH_RANGE_4G,
|
||||
"8G": LIS3DHRange.LIS3DH_RANGE_8G,
|
||||
"16G": LIS3DHRange.LIS3DH_RANGE_16G,
|
||||
}
|
||||
|
||||
LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate")
|
||||
DATA_RATE_OPTIONS = {
|
||||
"1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ,
|
||||
"10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ,
|
||||
"25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ,
|
||||
"50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ,
|
||||
"100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ,
|
||||
"200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ,
|
||||
"400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ,
|
||||
# 1620HZ is only available in low-power mode.
|
||||
"1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ,
|
||||
# 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode.
|
||||
"1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ,
|
||||
}
|
||||
|
||||
# Samples per second for each data rate key; INT1_DURATION counts samples.
|
||||
ODR_HZ = {
|
||||
"1HZ": 1,
|
||||
"10HZ": 10,
|
||||
"25HZ": 25,
|
||||
"50HZ": 50,
|
||||
"100HZ": 100,
|
||||
"200HZ": 200,
|
||||
"400HZ": 400,
|
||||
"1620HZ": 1620,
|
||||
"1344HZ": 1344,
|
||||
}
|
||||
DURATION_MAX_REG = 0x7F
|
||||
|
||||
LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode")
|
||||
OPERATING_MODE_OPTIONS = {
|
||||
"LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER,
|
||||
"NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL,
|
||||
"HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION,
|
||||
}
|
||||
|
||||
LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin")
|
||||
INTERRUPT_PIN_OPTIONS = {
|
||||
"INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1,
|
||||
"INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2,
|
||||
}
|
||||
|
||||
# INT1_CFG high-event bit per axis.
|
||||
INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20}
|
||||
|
||||
# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits.
|
||||
THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186}
|
||||
THRESHOLD_MAX_REG = 0x7F
|
||||
|
||||
|
||||
def _threshold_register(threshold_g: float, range_key: str) -> int:
|
||||
"""Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted."""
|
||||
return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1)
|
||||
|
||||
|
||||
def _duration_register(duration_ms: int, odr_key: str) -> int:
|
||||
"""Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate."""
|
||||
return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5)
|
||||
|
||||
|
||||
def _axes(value: object) -> list[str]:
|
||||
value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))(
|
||||
value
|
||||
)
|
||||
if len(set(value)) != len(value):
|
||||
raise cv.Invalid("Each axis may be listed only once")
|
||||
return value
|
||||
|
||||
|
||||
# Without high_pass_filter the threshold includes gravity, so it should exceed 1g.
|
||||
INTERRUPT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_PIN, default="INT1"): cv.enum(
|
||||
INTERRUPT_PIN_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All(
|
||||
cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float
|
||||
),
|
||||
cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_AXES, default=AXES): _axes,
|
||||
cv.Optional(CONF_LATCHED, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean,
|
||||
cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_odr_mode(config: ConfigType) -> ConfigType:
|
||||
# 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there.
|
||||
odr = config[CONF_ACCELEROMETER_ODR]
|
||||
low_power = config[CONF_OPERATING_MODE] == "LOW_POWER"
|
||||
if odr == "1620HZ" and not low_power:
|
||||
raise cv.Invalid(
|
||||
"'1620HZ' is only available with 'operating_mode: LOW_POWER'",
|
||||
path=[CONF_ACCELEROMETER_ODR],
|
||||
)
|
||||
if odr == "1344HZ" and low_power:
|
||||
raise cv.Invalid(
|
||||
"'1344HZ' is not available with 'operating_mode: LOW_POWER' "
|
||||
"(that combination runs at 5376Hz)",
|
||||
path=[CONF_ACCELEROMETER_ODR],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_interrupt(config: ConfigType) -> ConfigType:
|
||||
if (interrupt := config.get(CONF_INTERRUPT)) is None:
|
||||
return config
|
||||
range_key = config[CONF_ACCELEROMETER_RANGE]
|
||||
threshold = interrupt[CONF_THRESHOLD]
|
||||
max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000
|
||||
if threshold > max_g:
|
||||
raise cv.Invalid(
|
||||
f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'",
|
||||
path=[CONF_INTERRUPT, CONF_THRESHOLD],
|
||||
)
|
||||
if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0:
|
||||
_LOGGER.warning(
|
||||
"lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt "
|
||||
"asserted at rest; set 'high_pass_filter: true' to compare against motion only",
|
||||
threshold,
|
||||
)
|
||||
odr_key = config[CONF_ACCELEROMETER_ODR]
|
||||
duration_ms = interrupt[CONF_DURATION].total_milliseconds
|
||||
if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG:
|
||||
max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key])
|
||||
raise cv.Invalid(
|
||||
f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'",
|
||||
path=[CONF_INTERRUPT, CONF_DURATION],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum(
|
||||
RANGE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum(
|
||||
DATA_RATE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum(
|
||||
OPERATING_MODE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(i2c.i2c_device_schema(0x18)),
|
||||
_validate_odr_mode,
|
||||
_validate_interrupt,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await new_motion_component(config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE]))
|
||||
cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR]))
|
||||
cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE]))
|
||||
|
||||
if (interrupt := config.get(CONF_INTERRUPT)) is not None:
|
||||
threshold = _threshold_register(
|
||||
interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE]
|
||||
)
|
||||
axes_cfg = 0
|
||||
for axis in interrupt[CONF_AXES]:
|
||||
axes_cfg |= INT_CFG_HIGH_EVENT[axis]
|
||||
cg.add(
|
||||
var.set_interrupt(
|
||||
interrupt[CONF_PIN],
|
||||
threshold,
|
||||
_duration_register(
|
||||
interrupt[CONF_DURATION].total_milliseconds,
|
||||
config[CONF_ACCELEROMETER_ODR],
|
||||
),
|
||||
axes_cfg,
|
||||
interrupt[CONF_LATCHED],
|
||||
interrupt[CONF_ACTIVE_HIGH],
|
||||
interrupt[CONF_HIGH_PASS_FILTER],
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
ICON_THERMOMETER,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LIS3DH_ID, LIS3DHComponent
|
||||
|
||||
# The LIS3DH temperature sensor is uncalibrated and only reports relative
|
||||
# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty.
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
icon=ICON_THERMOMETER,
|
||||
accuracy_decimals=1,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
|
||||
cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
parent = await cg.get_variable(config[CONF_LIS3DH_ID])
|
||||
data = MockObj("data")
|
||||
value_lambda = await cg.process_lambda(
|
||||
var.publish_state(data),
|
||||
[(cg.float_, str(data))],
|
||||
)
|
||||
cg.add(parent.add_temperature_listener(value_lambda))
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for the lis3dh config cross-checks and interrupt threshold maths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lis3dh.motion import (
|
||||
CONF_ACCELEROMETER_ODR,
|
||||
CONF_ACCELEROMETER_RANGE,
|
||||
CONF_DURATION,
|
||||
CONF_HIGH_PASS_FILTER,
|
||||
CONF_INTERRUPT,
|
||||
CONF_OPERATING_MODE,
|
||||
CONF_THRESHOLD,
|
||||
_duration_register,
|
||||
_threshold_register,
|
||||
_validate_interrupt,
|
||||
_validate_odr_mode,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
|
||||
def _interrupt_config(
|
||||
range_key: str,
|
||||
odr_key: str,
|
||||
threshold_g: float,
|
||||
duration_ms: int,
|
||||
high_pass: bool = True,
|
||||
) -> dict:
|
||||
return {
|
||||
CONF_ACCELEROMETER_RANGE: range_key,
|
||||
CONF_ACCELEROMETER_ODR: odr_key,
|
||||
CONF_INTERRUPT: {
|
||||
CONF_THRESHOLD: threshold_g,
|
||||
CONF_DURATION: TimePeriod(milliseconds=duration_ms),
|
||||
CONF_HIGH_PASS_FILTER: high_pass,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["NORMAL", "HIGH_RESOLUTION"])
|
||||
def test_1620hz_needs_low_power(mode: str) -> None:
|
||||
"""1620Hz exists only in low power mode."""
|
||||
config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: mode}
|
||||
with pytest.raises(cv.Invalid, match="only available with"):
|
||||
_validate_odr_mode(config)
|
||||
|
||||
|
||||
def test_1344hz_is_rejected_in_low_power() -> None:
|
||||
"""The 1344Hz code runs at 5376Hz in low power mode."""
|
||||
config = {CONF_ACCELEROMETER_ODR: "1344HZ", CONF_OPERATING_MODE: "LOW_POWER"}
|
||||
with pytest.raises(cv.Invalid, match="not available with"):
|
||||
_validate_odr_mode(config)
|
||||
|
||||
|
||||
def test_1620hz_low_power_is_accepted() -> None:
|
||||
"""The one valid 1620Hz combination passes through unchanged."""
|
||||
config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: "LOW_POWER"}
|
||||
assert _validate_odr_mode(config) is config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("threshold_g", "range_key", "expected"),
|
||||
[
|
||||
(0.0, "2G", 1),
|
||||
(0.05, "16G", 1),
|
||||
(1.1, "2G", 69),
|
||||
(0.2, "2G", 13),
|
||||
(2.032, "2G", 127),
|
||||
(23.622, "16G", 127),
|
||||
],
|
||||
)
|
||||
def test_threshold_register(threshold_g: float, range_key: str, expected: int) -> None:
|
||||
"""INT1_THS is the nearest step and never zero."""
|
||||
assert _threshold_register(threshold_g, range_key) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("threshold_g", "range_key"),
|
||||
[(2.04, "2G"), (3.0, "2G"), (4.07, "4G"), (23.7, "16G")],
|
||||
)
|
||||
def test_threshold_above_range_is_rejected(threshold_g: float, range_key: str) -> None:
|
||||
"""A threshold the range cannot represent would never fire, so it fails validation."""
|
||||
config = _interrupt_config(range_key, "100HZ", threshold_g, 0)
|
||||
with pytest.raises(cv.Invalid, match="'threshold' can be at most"):
|
||||
_validate_interrupt(config)
|
||||
|
||||
|
||||
def test_threshold_within_range_is_accepted() -> None:
|
||||
"""The largest representable threshold is still valid."""
|
||||
config = _interrupt_config("2G", "100HZ", 2.032, 0)
|
||||
assert _validate_interrupt(config) is config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("duration_ms", "odr_key", "expected"),
|
||||
[
|
||||
(0, "100HZ", 0),
|
||||
(20, "100HZ", 2),
|
||||
(1, "1620HZ", 2),
|
||||
(10000, "1HZ", 10),
|
||||
(4, "100HZ", 0),
|
||||
],
|
||||
)
|
||||
def test_duration_register(duration_ms: int, odr_key: str, expected: int) -> None:
|
||||
"""INT1_DURATION is the nearest number of samples at the data rate."""
|
||||
assert _duration_register(duration_ms, odr_key) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("duration_ms", "odr_key"), [(1280, "100HZ"), (128000, "1HZ"), (79, "1620HZ")]
|
||||
)
|
||||
def test_duration_above_register_is_rejected(duration_ms: int, odr_key: str) -> None:
|
||||
"""A duration that needs more than 127 samples fails validation."""
|
||||
config = _interrupt_config("2G", odr_key, 1.1, duration_ms)
|
||||
with pytest.raises(cv.Invalid, match="'duration' can be at most"):
|
||||
_validate_interrupt(config)
|
||||
|
||||
|
||||
def test_duration_at_register_limit_is_accepted() -> None:
|
||||
"""127 samples is the largest INT1_DURATION value."""
|
||||
config = _interrupt_config("2G", "100HZ", 1.1, 1270)
|
||||
assert _validate_interrupt(config) is config
|
||||
|
||||
|
||||
def test_sub_gravity_threshold_without_filter_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Gravity alone can hold the pad asserted below 1g unless the high-pass filter is on."""
|
||||
config = _interrupt_config("2G", "100HZ", 0.5, 0, high_pass=False)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _validate_interrupt(config) is config
|
||||
assert "high_pass_filter: true" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("threshold_g", "high_pass"), [(1.1, False), (0.5, True)])
|
||||
def test_threshold_without_gravity_problem_is_silent(
|
||||
caplog: pytest.LogCaptureFixture, threshold_g: float, high_pass: bool
|
||||
) -> None:
|
||||
"""Above 1g, or with the filter on, no warning is logged."""
|
||||
config = _interrupt_config("2G", "100HZ", threshold_g, 0, high_pass=high_pass)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_validate_interrupt(config)
|
||||
assert caplog.text == ""
|
||||
@@ -0,0 +1,67 @@
|
||||
motion:
|
||||
- platform: lis3dh
|
||||
id: lis3dh_main
|
||||
i2c_id: i2c_bus
|
||||
accelerometer_range: 2G
|
||||
accelerometer_odr: 100HZ
|
||||
operating_mode: HIGH_RESOLUTION
|
||||
axis_map:
|
||||
x: y
|
||||
y: x
|
||||
z: -z
|
||||
# Motion (activity) interrupt - keeps the INT pad asserted while moving so it
|
||||
# can be used as the deep_sleep wakeup_pin.
|
||||
interrupt:
|
||||
pin: INT1
|
||||
threshold: 1.1g
|
||||
duration: 0ms
|
||||
axes: [x, y, z]
|
||||
latched: true
|
||||
active_high: true
|
||||
high_pass_filter: false
|
||||
|
||||
# A second device covering the other side of each option: low power mode with
|
||||
# the rate that only exists there, routed to INT2, active low, unlatched, and
|
||||
# with the high-pass filter on.
|
||||
- platform: lis3dh
|
||||
id: lis3dh_low_power
|
||||
i2c_id: i2c_bus
|
||||
address: 0x19
|
||||
accelerometer_range: 16G
|
||||
accelerometer_odr: 1620HZ
|
||||
operating_mode: LOW_POWER
|
||||
interrupt:
|
||||
pin: INT2
|
||||
threshold: 0.05g
|
||||
duration: 1ms
|
||||
axes: [z]
|
||||
latched: false
|
||||
active_high: false
|
||||
high_pass_filter: true
|
||||
|
||||
sensor:
|
||||
- platform: lis3dh
|
||||
lis3dh_id: lis3dh_main
|
||||
type: temperature
|
||||
name: "LIS3DH Temperature"
|
||||
|
||||
- platform: motion
|
||||
motion_id: lis3dh_main
|
||||
type: acceleration_x
|
||||
name: "Accel X"
|
||||
- platform: motion
|
||||
motion_id: lis3dh_main
|
||||
type: acceleration_y
|
||||
name: "Accel Y"
|
||||
- platform: motion
|
||||
motion_id: lis3dh_main
|
||||
type: acceleration_z
|
||||
name: "Accel Z"
|
||||
- platform: motion
|
||||
motion_id: lis3dh_main
|
||||
type: pitch
|
||||
name: "Pitch"
|
||||
- platform: motion
|
||||
motion_id: lis3dh_main
|
||||
type: roll
|
||||
name: "Roll"
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
lis3dh: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
|
||||
lis3dh: !include common.yaml
|
||||
Reference in New Issue
Block a user