diff --git a/CODEOWNERS b/CODEOWNERS index 2c026b92f5b..fc53a28c0b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 +esphome/components/icm20689/* @lboue esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core diff --git a/esphome/components/icm20689/__init__.py b/esphome/components/icm20689/__init__.py new file mode 100644 index 00000000000..b57221814e8 --- /dev/null +++ b/esphome/components/icm20689/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@lboue"] +DOMAIN = "icm20689" + +CONF_ICM20689_ID = "icm20689_id" +# C++ namespace / class +icm20689_ns = cg.esphome_ns.namespace("icm20689") +ICM20689Component = icm20689_ns.class_( + "ICM20689Component", MotionComponent, spi.SPIDevice +) diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp new file mode 100644 index 00000000000..7331a3fe4f6 --- /dev/null +++ b/esphome/components/icm20689/icm20689.cpp @@ -0,0 +1,126 @@ +#include "icm20689.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::icm20689 { + +ESPHOME_LOG_TAG(TAG, "icm20689"); + +namespace { + +// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range. +constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; } + +constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; } + +} // namespace + +void ICM20689Component::write_register_(uint8_t reg, uint8_t value) { + this->enable(); + this->write_byte(reg & 0x7F); + this->write_byte(value); + this->disable(); +} + +void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) { + this->enable(); + this->write_byte(reg | ICM20689_READ_BIT); + this->read_array(data, len); + this->disable(); +} + +void ICM20689Component::setup() { + MotionComponent::setup(); + + this->spi_setup(); + + if (!this->spi_is_ready()) { + ESP_LOGE(TAG, "SPI device is not ready"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Datasheet 4.20: a soft reset must be the first operation after power-up, + // before any register initialization. WHO_AM_I is only valid afterwards. + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET); + delay(100); // NOLINT + + // The chip comes out of reset in sleep mode: wake it and select the + // auto-selected best clock source (required for full gyro performance). + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO); + + // USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake. + // Datasheet 6.1: disable the I2C interface when using SPI. + this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS); + + uint8_t who_am_i = 0; + this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1); + + if (who_am_i != ICM20689_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Allow the sensor to settle after waking from sleep. + delay(50); // NOLINT + + // Configure the accelerometer and gyroscope full-scale ranges. + this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast(this->accel_range_) << 3); + this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast(this->gyro_range_) << 3); +} + +void ICM20689Component::dump_config() { + ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); + LOG_PIN(" CS Pin: ", this->cs_); + + if (this->is_failed()) { + ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); + ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); + + MotionComponent::dump_config(); +} + +bool ICM20689Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + const uint8_t accel_range = static_cast(this->accel_range_); + const uint8_t gyro_range = static_cast(this->gyro_range_); + + // Read one contiguous sample: + // 0..5 accelerometer X/Y/Z + // 6..7 temperature + // 8..13 gyroscope X/Y/Z + uint8_t raw_data[14] = {}; + this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data)); + + const float accel_factor = accel_scale(accel_range); + + data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; + data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; + data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + + const float gyro_factor = gyro_scale(gyro_range); + + data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + + if (!this->temperature_callback_.empty()) { + // Datasheet formula: temperature (°C) = raw / 326.8 + 25. + const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + this->temperature_callback_.call(temperature); + } + + return true; +} + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/icm20689.h b/esphome/components/icm20689/icm20689.h new file mode 100644 index 00000000000..08ed9d03eff --- /dev/null +++ b/esphome/components/icm20689/icm20689.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::icm20689 { + +// Register map +static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75; +static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B; +static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A; +static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B; +static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C; +static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B; +static constexpr uint8_t ICM20689_READ_BIT = 0x80; +static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98; +static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20) +static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock) +static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4 + +// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG +enum ICM20689AccelRange : uint8_t { + ICM20689_ACCEL_RANGE_2G = 0x00, + ICM20689_ACCEL_RANGE_4G = 0x01, + ICM20689_ACCEL_RANGE_8G = 0x02, + ICM20689_ACCEL_RANGE_16G = 0x03, +}; + +// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG +enum ICM20689GyroRange : uint8_t { + ICM20689_GYRO_RANGE_250 = 0x00, + ICM20689_GYRO_RANGE_500 = 0x01, + ICM20689_GYRO_RANGE_1000 = 0x02, + ICM20689_GYRO_RANGE_2000 = 0x03, +}; + +class ICM20689Component final : public motion::MotionComponent, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; } + void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; } + + template void add_temperature_listener(F &&callback) { + this->temperature_callback_.add(std::forward(callback)); + } + + protected: + bool update_data(motion::MotionData &data) override; + + void write_register_(uint8_t reg, uint8_t value); + // Reads len bytes starting at reg in one CS-low transaction. + void read_registers_(uint8_t reg, uint8_t *data, size_t len); + + ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G}; + ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py new file mode 100644 index 00000000000..7673dcf4cf4 --- /dev/null +++ b/esphome/components/icm20689/motion.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ICM20689Component, icm20689_ns + +DEPENDENCIES = ["spi"] + +# Enum proxies (must match the C++ enum values exactly) +ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, + "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, + "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, + "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, +} + +ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") +GYRO_RANGE_OPTIONS = { + "250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250, + "500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500, + "1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000, + "2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(ICM20689Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + } + ) + .extend( + spi.spi_device_schema( + cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz" + ) + ) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await spi.register_spi_device(var, config) + + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) diff --git a/esphome/components/icm20689/sensor.py b/esphome/components/icm20689/sensor.py new file mode 100644 index 00000000000..7cd5dedba07 --- /dev/null +++ b/esphome/components/icm20689/sensor.py @@ -0,0 +1,42 @@ +# YAML config keys +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_ICM20689_ID, ICM20689Component + +AUTO_LOAD = ["icm20689"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_ICM20689_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)) diff --git a/tests/components/icm20689/common.yaml b/tests/components/icm20689/common.yaml new file mode 100644 index 00000000000..522a0a2bbf7 --- /dev/null +++ b/tests/components/icm20689/common.yaml @@ -0,0 +1,73 @@ +sensor: + - platform: icm20689 + type: temperature + name: "ICM20689 Temperature" + + - platform: motion + motion_id: icm20689_motion + type: acceleration_x + name: "ICM20689 Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + motion_id: icm20689_motion + type: acceleration_y + name: "ICM20689 Accel Y" + accuracy_decimals: 4 + - platform: motion + motion_id: icm20689_motion + type: acceleration_z + name: "ICM20689 Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + motion_id: icm20689_motion + type: gyroscope_x + name: "ICM20689 Gyro X" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_y + name: "ICM20689 Gyro Y" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_z + name: "ICM20689 Gyro Z" + + - platform: motion + motion_id: icm20689_motion + type: angular_rate_x + name: "ICM20689 Angular Rate X" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_y + name: "ICM20689 Angular Rate Y" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_z + name: "ICM20689 Angular Rate Z" + + - platform: motion + motion_id: icm20689_motion + type: pitch + name: "ICM20689 Pitch" + - platform: motion + motion_id: icm20689_motion + type: roll + name: "ICM20689 Roll" + +motion: + - platform: icm20689 + id: icm20689_motion + cs_pin: GPIO5 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + # Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 500DPS + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/icm20689/test.esp32-idf.yaml b/tests/components/icm20689/test.esp32-idf.yaml new file mode 100644 index 00000000000..0c9ea68ef00 --- /dev/null +++ b/tests/components/icm20689/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.esp8266-ard.yaml b/tests/components/icm20689/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ab217e25fb8 --- /dev/null +++ b/tests/components/icm20689/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.rp2040-ard.yaml b/tests/components/icm20689/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fc5a73cf62d --- /dev/null +++ b/tests/components/icm20689/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + icm20689: !include common.yaml