[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:
Zebble
2026-10-12 11:44:33 -10:00
committed by GitHub
co-authored by Claude pre-commit-ci-lite[bot] J. Nick Koston
parent 93183c661e
commit 584d4878eb
11 changed files with 821 additions and 0 deletions
+1
View File
@@ -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
+15
View File
@@ -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 = {}
+195
View File
@@ -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
+135
View File
@@ -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
+214
View File
@@ -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],
)
)
+41
View File
@@ -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))
+147
View File
@@ -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 == ""
+67
View File
@@ -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