diff --git a/CODEOWNERS b/CODEOWNERS index 2b0aefb569..74cda0fe9c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -299,6 +299,7 @@ esphome/components/mopeka_std_check/* @Fabian-Schmidt esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta +esphome/components/msa3xx/* @latonita esphome/components/nau7802/* @cujomalainey esphome/components/network/* @esphome/core esphome/components/nextion/* @edwardtfn @senexcrenshaw diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 119a358e1d..56a5a4b9e4 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -462,8 +462,6 @@ CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" -CONF_OFFSET_X = "offset_x" -CONF_OFFSET_Y = "offset_y" CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" CONF_ON_PAUSE = "on_pause" diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 46077190d0..d9de8d821a 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,11 +1,9 @@ import esphome.config_validation as cv -from esphome.const import CONF_ANGLE, CONF_MODE +from esphome.const import CONF_ANGLE, CONF_MODE, CONF_OFFSET_X, CONF_OFFSET_Y from ..defines import ( CONF_ANTIALIAS, CONF_MAIN, - CONF_OFFSET_X, - CONF_OFFSET_Y, CONF_PIVOT_X, CONF_PIVOT_Y, CONF_SRC, diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py new file mode 100644 index 0000000000..04514b584f --- /dev/null +++ b/esphome/components/msa3xx/__init__.py @@ -0,0 +1,189 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_OFFSET_Z, + CONF_RANGE, + CONF_RESOLUTION, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_TYPE, +) + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] + +MULTI_CONF = True + +CONF_MSA3XX_ID = "msa3xx_id" + +CONF_MIRROR_Z = "mirror_z" +CONF_ON_ACTIVE = "on_active" +CONF_ON_DOUBLE_TAP = "on_double_tap" +CONF_ON_FREEFALL = "on_freefall" +CONF_ON_ORIENTATION = "on_orientation" +CONF_ON_TAP = "on_tap" + +MODEL_MSA301 = "MSA301" +MODEL_MSA311 = "MSA311" + +msa3xx_ns = cg.esphome_ns.namespace("msa3xx") +MSA3xxComponent = msa3xx_ns.class_( + "MSA3xxComponent", cg.PollingComponent, i2c.I2CDevice +) + +MSAModels = msa3xx_ns.enum("Model", True) +MSA_MODELS = { + MODEL_MSA301: MSAModels.MSA301, + MODEL_MSA311: MSAModels.MSA311, +} + +MSARange = msa3xx_ns.enum("Range", True) +MSA_RANGES = { + "2G": MSARange.RANGE_2G, + "4G": MSARange.RANGE_4G, + "8G": MSARange.RANGE_8G, + "16G": MSARange.RANGE_16G, +} + +MSAResolution = msa3xx_ns.enum("Resolution", True) +RESOLUTIONS_MSA301 = { + 14: MSAResolution.RES_14BIT, + 12: MSAResolution.RES_12BIT, + 10: MSAResolution.RES_10BIT, + 8: MSAResolution.RES_8BIT, +} + +RESOLUTIONS_MSA311 = { + 12: MSAResolution.RES_12BIT, +} + +_COMMON_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MSA3xxComponent), + cv.Optional(CONF_RANGE, default="2G"): cv.enum(MSA_RANGES, upper=True), + cv.Optional(CONF_CALIBRATION): cv.Schema( + { + cv.Optional(CONF_OFFSET_X, default=0): cv.float_range( + min=-4.5, max=4.5 + ), + cv.Optional(CONF_OFFSET_Y, default=0): cv.float_range( + min=-4.5, max=4.5 + ), + cv.Optional(CONF_OFFSET_Z, default=0): cv.float_range( + min=-4.5, max=4.5 + ), + } + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Z, default=False): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional(CONF_ON_ACTIVE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_TAP): automation.validate_automation(single=True), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation(single=True), + cv.Optional(CONF_ON_FREEFALL): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ORIENTATION): automation.validate_automation(single=True), + } +).extend(cv.polling_component_schema("10s")) + + +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_MSA301: _COMMON_SCHEMA.extend( + { + cv.Optional(CONF_RESOLUTION, default=14): cv.enum(RESOLUTIONS_MSA301), + } + ).extend(i2c.i2c_device_schema(0x26)), + MODEL_MSA311: _COMMON_SCHEMA.extend( + { + cv.Optional(CONF_RESOLUTION, default=12): cv.enum(RESOLUTIONS_MSA311), + } + ).extend(i2c.i2c_device_schema(0x62)), + }, + upper=True, + enum=MSA_MODELS, +) + +MSA_SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MSA3XX_ID): cv.use_id(MSA3xxComponent), + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_model(config[CONF_TYPE])) + cg.add(var.set_range(MSA_RANGES[config[CONF_RANGE]])) + cg.add(var.set_resolution(RESOLUTIONS_MSA301[config[CONF_RESOLUTION]])) + + if transform := config.get(CONF_TRANSFORM): + cg.add( + var.set_transform( + transform[CONF_MIRROR_X], + transform[CONF_MIRROR_Y], + transform[CONF_MIRROR_Z], + transform[CONF_SWAP_XY], + ) + ) + + if calibration_config := config.get(CONF_CALIBRATION): + cg.add( + var.set_offset( + calibration_config[CONF_OFFSET_X], + calibration_config[CONF_OFFSET_Y], + calibration_config[CONF_OFFSET_Z], + ) + ) + + # Triggers secton + + if CONF_ON_ORIENTATION in config: + await automation.build_automation( + var.get_orientation_trigger(), + [], + config[CONF_ON_ORIENTATION], + ) + + if CONF_ON_TAP in config: + await automation.build_automation( + var.get_tap_trigger(), + [], + config[CONF_ON_TAP], + ) + + if CONF_ON_DOUBLE_TAP in config: + await automation.build_automation( + var.get_double_tap_trigger(), + [], + config[CONF_ON_DOUBLE_TAP], + ) + + if CONF_ON_ACTIVE in config: + await automation.build_automation( + var.get_active_trigger(), + [], + config[CONF_ON_ACTIVE], + ) + + if CONF_ON_FREEFALL in config: + await automation.build_automation( + var.get_freefall_trigger(), + [], + config[CONF_ON_FREEFALL], + ) diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py new file mode 100644 index 0000000000..793d5190af --- /dev/null +++ b/esphome/components/msa3xx/binary_sensor.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE + +from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["msa3xx"] + +CONF_TAP = "tap" +CONF_DOUBLE_TAP = "double_tap" + +ICON_TAP = "mdi:gesture-tap" +ICON_DOUBLE_TAP = "mdi:gesture-double-tap" + +EVENT_SENSORS = (CONF_TAP, CONF_DOUBLE_TAP, CONF_ACTIVE) +ICONS = (ICON_TAP, ICON_DOUBLE_TAP, ICON_VIBRATE) + +CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( + { + cv.Optional(event): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_VIBRATION, + icon=icon, + ), + key=CONF_NAME, + ) + for event, icon in zip(EVENT_SENSORS, ICONS) + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_MSA3XX_ID]) + + for sensor in EVENT_SENSORS: + if sensor in config: + sens = await binary_sensor.new_binary_sensor(config[sensor]) + cg.add(getattr(hub, f"set_{sensor}_binary_sensor")(sens)) diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp new file mode 100644 index 0000000000..8ecb319955 --- /dev/null +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -0,0 +1,417 @@ +#include "msa3xx.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace msa3xx { + +static const char *const TAG = "msa3xx"; + +const uint8_t MSA_3XX_PART_ID = 0x13; + +const float GRAVITY_EARTH = 9.80665f; +const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9); // LSB to 1 LSB = 3.9mg = 0.0039g +const float G_OFFSET_MIN = -4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe +const float G_OFFSET_MAX = 4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe + +const uint8_t RESOLUTION[] = {14, 12, 10, 8}; + +const uint32_t TAP_COOLDOWN_MS = 500; +const uint32_t DOUBLE_TAP_COOLDOWN_MS = 500; +const uint32_t ACTIVITY_COOLDOWN_MS = 500; + +const char *model_to_string(Model model) { + switch (model) { + case Model::MSA301: + return "MSA301"; + case Model::MSA311: + return "MSA311"; + default: + return "Unknown"; + } +} + +const char *power_mode_to_string(PowerMode power_mode) { + switch (power_mode) { + case PowerMode::NORMAL: + return "Normal"; + case PowerMode::LOW_POWER: + return "Low Power"; + case PowerMode::SUSPEND: + return "Suspend"; + default: + return "Unknown"; + } +} + +const char *res_to_string(Resolution resolution) { + switch (resolution) { + case Resolution::RES_14BIT: + return "14-bit"; + case Resolution::RES_12BIT: + return "12-bit"; + case Resolution::RES_10BIT: + return "10-bit"; + case Resolution::RES_8BIT: + return "8-bit"; + default: + return "Unknown"; + } +} + +const char *range_to_string(Range range) { + switch (range) { + case Range::RANGE_2G: + return "±2g"; + case Range::RANGE_4G: + return "±4g"; + case Range::RANGE_8G: + return "±8g"; + case Range::RANGE_16G: + return "±16g"; + default: + return "Unknown"; + } +} + +const char *bandwidth_to_string(Bandwidth bandwidth) { + switch (bandwidth) { + case Bandwidth::BW_1_95HZ: + return "1.95 Hz"; + case Bandwidth::BW_3_9HZ: + return "3.9 Hz"; + case Bandwidth::BW_7_81HZ: + return "7.81 Hz"; + case Bandwidth::BW_15_63HZ: + return "15.63 Hz"; + case Bandwidth::BW_31_25HZ: + return "31.25 Hz"; + case Bandwidth::BW_62_5HZ: + return "62.5 Hz"; + case Bandwidth::BW_125HZ: + return "125 Hz"; + case Bandwidth::BW_250HZ: + return "250 Hz"; + case Bandwidth::BW_500HZ: + return "500 Hz"; + default: + return "Unknown"; + } +} + +const char *orientation_xy_to_string(OrientationXY orientation) { + switch (orientation) { + case OrientationXY::PORTRAIT_UPRIGHT: + return "Portrait Upright"; + case OrientationXY::PORTRAIT_UPSIDE_DOWN: + return "Portrait Upside Down"; + case OrientationXY::LANDSCAPE_LEFT: + return "Landscape Left"; + case OrientationXY::LANDSCAPE_RIGHT: + return "Landscape Right"; + default: + return "Unknown"; + } +} + +const char *orientation_z_to_string(bool orientation) { return orientation ? "Downwards looking" : "Upwards looking"; } + +void MSA3xxComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up MSA3xx..."); + + uint8_t part_id{0xff}; + if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id) || (part_id != MSA_3XX_PART_ID)) { + ESP_LOGE(TAG, "Part ID is wrong or missing. Got 0x%02X", part_id); + this->mark_failed(); + return; + } + + // Resolution LSB/g + // Range : MSA301 : MSA311 + // S2g : 1024 (2^10) : 4096 (2^12) + // S4g : 512 (2^9) : 2048 (2^11) + // S8g : 256 (2^8) : 1024 (2^10) + // S16g : 128 (2^7) : 512 (2^9) + if (this->model_ == Model::MSA301) { + this->device_params_.accel_data_width = 14; + this->device_params_.scale_factor_exp = static_cast(this->range_) - 12; + } else if (this->model_ == Model::MSA311) { + this->device_params_.accel_data_width = 12; + this->device_params_.scale_factor_exp = static_cast(this->range_) - 10; + } else { + ESP_LOGE(TAG, "Unknown model"); + this->mark_failed(); + return; + } + + this->setup_odr_(this->data_rate_); + this->setup_power_mode_bandwidth_(this->power_mode_, this->bandwidth_); + this->setup_range_resolution_(this->range_, this->resolution_); // 2g...16g, 14...8 bit + this->setup_offset_(this->offset_x_, this->offset_y_, this->offset_z_); // calibration offsets + this->write_byte(static_cast(RegisterMap::TAP_DURATION), 0b11000100); // set tap duration 250ms + this->write_byte(static_cast(RegisterMap::SWAP_POLARITY), this->swap_.raw); // set axes polarity + this->write_byte(static_cast(RegisterMap::INT_SET_0), 0b01110111); // enable all interrupts + this->write_byte(static_cast(RegisterMap::INT_SET_1), 0b00011000); // including orientation +} + +void MSA3xxComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MSA3xx:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Communication with MSA3xx failed!"); + } + ESP_LOGCONFIG(TAG, " Model: %s", model_to_string(this->model_)); + ESP_LOGCONFIG(TAG, " Power Mode: %s", power_mode_to_string(this->power_mode_)); + ESP_LOGCONFIG(TAG, " Bandwidth: %s", bandwidth_to_string(this->bandwidth_)); + ESP_LOGCONFIG(TAG, " Range: %s", range_to_string(this->range_)); + ESP_LOGCONFIG(TAG, " Resolution: %s", res_to_string(this->resolution_)); + ESP_LOGCONFIG(TAG, " Offsets: {%.3f m/s², %.3f m/s², %.3f m/s²}", this->offset_x_, this->offset_y_, this->offset_z_); + ESP_LOGCONFIG(TAG, " Transform: {mirror_x=%s, mirror_y=%s, mirror_z=%s, swap_xy=%s}", YESNO(this->swap_.x_polarity), + YESNO(this->swap_.y_polarity), YESNO(this->swap_.z_polarity), YESNO(this->swap_.x_y_swap)); + LOG_UPDATE_INTERVAL(this); + +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Tap", this->tap_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Double Tap", this->double_tap_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Active", this->active_binary_sensor_); +#endif + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Acceleration X", this->acceleration_x_sensor_); + LOG_SENSOR(" ", "Acceleration Y", this->acceleration_y_sensor_); + LOG_SENSOR(" ", "Acceleration Z", this->acceleration_z_sensor_); +#endif + +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Orientation XY", this->orientation_xy_text_sensor_); + LOG_TEXT_SENSOR(" ", "Orientation Z", this->orientation_z_text_sensor_); +#endif +} + +bool MSA3xxComponent::read_data_() { + uint8_t accel_data[6]; + if (!this->read_bytes(static_cast(RegisterMap::ACC_X_LSB), accel_data, 6)) { + return false; + } + + auto raw_to_x_bit = [](uint16_t lsb, uint16_t msb, uint8_t data_bits) -> uint16_t { + return ((msb << 8) | lsb) >> (16 - data_bits); + }; + + auto lpf = [](float new_value, float old_value, float alpha = 0.5f) { + return alpha * new_value + (1.0f - alpha) * old_value; + }; + + this->data_.lsb_x = + this->twos_complement_(raw_to_x_bit(accel_data[0], accel_data[1], this->device_params_.accel_data_width), + this->device_params_.accel_data_width); + this->data_.lsb_y = + this->twos_complement_(raw_to_x_bit(accel_data[2], accel_data[3], this->device_params_.accel_data_width), + this->device_params_.accel_data_width); + this->data_.lsb_z = + this->twos_complement_(raw_to_x_bit(accel_data[4], accel_data[5], this->device_params_.accel_data_width), + this->device_params_.accel_data_width); + + this->data_.x = lpf(ldexp(this->data_.lsb_x, this->device_params_.scale_factor_exp) * GRAVITY_EARTH, this->data_.x); + this->data_.y = lpf(ldexp(this->data_.lsb_y, this->device_params_.scale_factor_exp) * GRAVITY_EARTH, this->data_.y); + this->data_.z = lpf(ldexp(this->data_.lsb_z, this->device_params_.scale_factor_exp) * GRAVITY_EARTH, this->data_.z); + + return true; +} + +bool MSA3xxComponent::read_motion_status_() { + if (!this->read_byte(static_cast(RegisterMap::MOTION_INTERRUPT), &this->status_.motion_int.raw)) { + return false; + } + + if (!this->read_byte(static_cast(RegisterMap::ORIENTATION_STATUS), &this->status_.orientation.raw)) { + return false; + } + + return true; +} + +void MSA3xxComponent::loop() { + if (!this->is_ready()) { + return; + } + + RegMotionInterrupt old_motion_int = this->status_.motion_int; + + if (!this->read_data_() || !this->read_motion_status_()) { + this->status_set_warning(); + return; + } + + this->process_motions_(old_motion_int); +} + +void MSA3xxComponent::update() { + ESP_LOGV(TAG, "Updating MSA3xx..."); + + if (!this->is_ready()) { + ESP_LOGV(TAG, "Component MSA3xx not ready for update"); + return; + } + ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}; ", this->data_.x, this->data_.y, + this->data_.z); + + ESP_LOGV(TAG, "Orientation: {XY = %s, Z = %s}", orientation_xy_to_string(this->status_.orientation.orient_xy), + orientation_z_to_string(this->status_.orientation.orient_z)); + +#ifdef USE_SENSOR + if (this->acceleration_x_sensor_ != nullptr) + this->acceleration_x_sensor_->publish_state(this->data_.x); + if (this->acceleration_y_sensor_ != nullptr) + this->acceleration_y_sensor_->publish_state(this->data_.y); + if (this->acceleration_z_sensor_ != nullptr) + this->acceleration_z_sensor_->publish_state(this->data_.z); +#endif + +#ifdef USE_TEXT_SENSOR + if (this->orientation_xy_text_sensor_ != nullptr && + (this->status_.orientation.orient_xy != this->status_.orientation_old.orient_xy || + this->status_.never_published)) { + this->orientation_xy_text_sensor_->publish_state(orientation_xy_to_string(this->status_.orientation.orient_xy)); + } + if (this->orientation_z_text_sensor_ != nullptr && + (this->status_.orientation.orient_z != this->status_.orientation_old.orient_z || this->status_.never_published)) { + this->orientation_z_text_sensor_->publish_state(orientation_z_to_string(this->status_.orientation.orient_z)); + } + this->status_.orientation_old = this->status_.orientation; +#endif + + this->status_.never_published = false; + this->status_clear_warning(); +} +float MSA3xxComponent::get_setup_priority() const { return setup_priority::DATA; } + +void MSA3xxComponent::set_offset(float offset_x, float offset_y, float offset_z) { + this->offset_x_ = offset_x; + this->offset_y_ = offset_y; + this->offset_z_ = offset_z; +} + +void MSA3xxComponent::set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy) { + this->swap_.x_polarity = mirror_x; + this->swap_.y_polarity = mirror_y; + this->swap_.z_polarity = mirror_z; + this->swap_.x_y_swap = swap_xy; +} + +void MSA3xxComponent::setup_odr_(DataRate rate) { + RegOutputDataRate reg_odr; + auto reg = this->read_byte(static_cast(RegisterMap::ODR)); + if (reg.has_value()) { + reg_odr.raw = reg.value(); + } else { + reg_odr.raw = 0x0F; // defaut from datasheet + } + + reg_odr.x_axis_disable = false; + reg_odr.y_axis_disable = false; + reg_odr.z_axis_disable = false; + reg_odr.odr = rate; + + this->write_byte(static_cast(RegisterMap::ODR), reg_odr.raw); +} + +void MSA3xxComponent::setup_power_mode_bandwidth_(PowerMode power_mode, Bandwidth bandwidth) { + // 0x11 POWER_MODE_BANDWIDTH + auto reg = this->read_byte(static_cast(RegisterMap::POWER_MODE_BANDWIDTH)); + + RegPowerModeBandwidth power_mode_bandwidth; + if (reg.has_value()) { + power_mode_bandwidth.raw = reg.value(); + } else { + power_mode_bandwidth.raw = 0xde; // defaut from datasheet + } + + power_mode_bandwidth.power_mode = power_mode; + power_mode_bandwidth.low_power_bandwidth = bandwidth; + + this->write_byte(static_cast(RegisterMap::POWER_MODE_BANDWIDTH), power_mode_bandwidth.raw); +} + +void MSA3xxComponent::setup_range_resolution_(Range range, Resolution resolution) { + RegRangeResolution reg; + reg.raw = this->read_byte(static_cast(RegisterMap::RANGE_RESOLUTION)).value_or(0x00); + reg.range = range; + if (this->model_ == Model::MSA301) { + reg.resolution = resolution; + } + this->write_byte(static_cast(RegisterMap::RANGE_RESOLUTION), reg.raw); +} + +void MSA3xxComponent::setup_offset_(float offset_x, float offset_y, float offset_z) { + uint8_t offset[3]; + + auto offset_g_to_lsb = [](float accel) -> int8_t { + float acccel_clamped = clamp(accel, G_OFFSET_MIN, G_OFFSET_MAX); + return static_cast(acccel_clamped * LSB_COEFF); + }; + + offset[0] = offset_g_to_lsb(offset_x); + offset[1] = offset_g_to_lsb(offset_y); + offset[2] = offset_g_to_lsb(offset_z); + + ESP_LOGV(TAG, "Offset (%.3f, %.3f, %.3f)=>LSB(%d, %d, %d)", offset_x, offset_y, offset_z, offset[0], offset[1], + offset[2]); + + this->write_bytes(static_cast(RegisterMap::OFFSET_COMP_X), (uint8_t *) &offset, 3); +} + +int64_t MSA3xxComponent::twos_complement_(uint64_t value, uint8_t bits) { + if (value > (1ULL << (bits - 1))) { + return (int64_t) (value - (1ULL << bits)); + } else { + return (int64_t) value; + } +} + +void binary_event_debounce(bool state, bool old_state, uint32_t now, uint32_t &last_ms, Trigger<> &trigger, + uint32_t cooldown_ms, void *bs, const char *desc) { + if (state && now - last_ms > cooldown_ms) { + ESP_LOGV(TAG, "%s detected", desc); + trigger.trigger(); + last_ms = now; +#ifdef USE_BINARY_SENSOR + if (bs != nullptr) { + static_cast(bs)->publish_state(true); + } +#endif + } else if (!state && now - last_ms > cooldown_ms && bs != nullptr) { +#ifdef USE_BINARY_SENSOR + static_cast(bs)->publish_state(false); +#endif + } +} + +#ifdef USE_BINARY_SENSOR +#define BS_OPTIONAL_PTR(x) ((void *) (x)) +#else +#define BS_OPTIONAL_PTR(x) (nullptr) +#endif + +void MSA3xxComponent::process_motions_(RegMotionInterrupt old) { + uint32_t now = millis(); + + binary_event_debounce(this->status_.motion_int.single_tap_interrupt, old.single_tap_interrupt, now, + this->status_.last_tap_ms, this->tap_trigger_, TAP_COOLDOWN_MS, + BS_OPTIONAL_PTR(this->tap_binary_sensor_), "Tap"); + binary_event_debounce(this->status_.motion_int.double_tap_interrupt, old.double_tap_interrupt, now, + this->status_.last_double_tap_ms, this->double_tap_trigger_, DOUBLE_TAP_COOLDOWN_MS, + BS_OPTIONAL_PTR(this->double_tap_binary_sensor_), "Double Tap"); + binary_event_debounce(this->status_.motion_int.active_interrupt, old.active_interrupt, now, + this->status_.last_action_ms, this->active_trigger_, ACTIVITY_COOLDOWN_MS, + BS_OPTIONAL_PTR(this->active_binary_sensor_), "Activity"); + + if (this->status_.motion_int.orientation_interrupt) { + ESP_LOGVV(TAG, "Orientation changed"); + this->orientation_trigger_.trigger(); + } +} + +} // namespace msa3xx +} // namespace esphome diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h new file mode 100644 index 0000000000..644109dab0 --- /dev/null +++ b/esphome/components/msa3xx/msa3xx.h @@ -0,0 +1,311 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/automation.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome { +namespace msa3xx { + +// Combined register map of MSA301 and MSA311 +// Differences +// What | MSA301 | MSA11 | +// - Resolution | 14-bit | 12-bit | +// + +// I2c address +enum class Model : uint8_t { + MSA301 = 0x26, + MSA311 = 0x62, +}; + +// Combined MSA301 and MSA311 register map +enum class RegisterMap : uint8_t { + SOFT_RESET = 0x00, + PART_ID = 0x01, + ACC_X_LSB = 0x02, + ACC_X_MSB = 0x03, + ACC_Y_LSB = 0x04, + ACC_Y_MSB = 0x05, + ACC_Z_LSB = 0x06, + ACC_Z_MSB = 0x07, + MOTION_INTERRUPT = 0x09, + DATA_INTERRUPT = 0x0A, + TAP_ACTIVE_STATUS = 0x0B, + ORIENTATION_STATUS = 0x0C, + RESOLUTION_RANGE_CONFIG = 0x0D, + RANGE_RESOLUTION = 0x0F, + ODR = 0x10, + POWER_MODE_BANDWIDTH = 0x11, + SWAP_POLARITY = 0x12, + INT_SET_0 = 0x16, + INT_SET_1 = 0x17, + INT_MAP_0 = 0x19, + INT_MAP_1 = 0x1A, + INT_CONFIG = 0x20, + INT_LATCH = 0x21, + FREEFALL_DURATION = 0x22, + FREEFALL_THRESHOLD = 0x23, + FREEFALL_HYSTERESIS = 0x24, + ACTIVE_DURATION = 0x27, + ACTIVE_THRESHOLD = 0x28, + TAP_DURATION = 0x2A, + TAP_THRESHOLD = 0x2B, + ORIENTATION_CONFIG = 0x2C, + Z_BLOCK = 0x2D, + OFFSET_COMP_X = 0x38, + OFFSET_COMP_Y = 0x39, + OFFSET_COMP_Z = 0x3A, +}; + +enum class Range : uint8_t { + RANGE_2G = 0b00, + RANGE_4G = 0b01, + RANGE_8G = 0b10, + RANGE_16G = 0b11, +}; + +enum class Resolution : uint8_t { + RES_14BIT = 0b00, + RES_12BIT = 0b01, + RES_10BIT = 0b10, + RES_8BIT = 0b11, +}; + +enum class PowerMode : uint8_t { + NORMAL = 0b00, + LOW_POWER = 0b01, + SUSPEND = 0b11, +}; + +enum class Bandwidth : uint8_t { + BW_1_95HZ = 0b0000, + BW_3_9HZ = 0b0011, + BW_7_81HZ = 0b0100, + BW_15_63HZ = 0b0101, + BW_31_25HZ = 0b0110, + BW_62_5HZ = 0b0111, + BW_125HZ = 0b1000, + BW_250HZ = 0b1001, + BW_500HZ = 0b1010, +}; + +enum class DataRate : uint8_t { + ODR_1HZ = 0b0000, // not available in normal mode + ODR_1_95HZ = 0b0001, // not available in normal mode + ODR_3_9HZ = 0b0010, + ODR_7_81HZ = 0b0011, + ODR_15_63HZ = 0b0100, + ODR_31_25HZ = 0b0101, + ODR_62_5HZ = 0b0110, + ODR_125HZ = 0b0111, + ODR_250HZ = 0b1000, + ODR_500HZ = 0b1001, // not available in low power mode + ODR_1000HZ = 0b1010, // not available in low power mode +}; + +enum class OrientationXY : uint8_t { + PORTRAIT_UPRIGHT = 0b00, + PORTRAIT_UPSIDE_DOWN = 0b01, + LANDSCAPE_LEFT = 0b10, + LANDSCAPE_RIGHT = 0b11, +}; + +union Orientation { + struct { + OrientationXY xy : 2; + bool z : 1; + uint8_t reserved : 5; + } __attribute__((packed)); + uint8_t raw; +}; + +// 0x09 +union RegMotionInterrupt { + struct { + bool freefall_interrupt : 1; + bool reserved_1 : 1; + bool active_interrupt : 1; + bool reserved_3 : 1; + bool double_tap_interrupt : 1; + bool single_tap_interrupt : 1; + bool orientation_interrupt : 1; + bool reserved_7 : 1; + } __attribute__((packed)); + uint8_t raw; +}; + +// 0x0C +union RegOrientationStatus { + struct { + uint8_t reserved_0_3 : 4; + OrientationXY orient_xy : 2; + bool orient_z : 1; + uint8_t reserved_7 : 1; + } __attribute__((packed)); + uint8_t raw{0x00}; +}; + +// 0x0f +union RegRangeResolution { + struct { + Range range : 2; + Resolution resolution : 2; + uint8_t reserved_2 : 4; + } __attribute__((packed)); + uint8_t raw{0x00}; +}; + +// 0x10 +union RegOutputDataRate { + struct { + DataRate odr : 4; + uint8_t reserved_4 : 1; + bool z_axis_disable : 1; + bool y_axis_disable : 1; + bool x_axis_disable : 1; + } __attribute__((packed)); + uint8_t raw{0xde}; +}; + +// 0x11 +union RegPowerModeBandwidth { + struct { + uint8_t reserved_0 : 1; + Bandwidth low_power_bandwidth : 4; + uint8_t reserved_5 : 1; + PowerMode power_mode : 2; + } __attribute__((packed)); + uint8_t raw{0xde}; +}; + +// 0x12 +union RegSwapPolarity { + struct { + bool x_y_swap : 1; + bool z_polarity : 1; + bool y_polarity : 1; + bool x_polarity : 1; + uint8_t reserved : 4; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// 0x2a +union RegTapDuration { + struct { + uint8_t duration : 3; + uint8_t reserved : 3; + bool tap_shock : 1; + bool tap_quiet : 1; + } __attribute__((packed)); + uint8_t raw{0x04}; +}; + +class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void loop() override; + void update() override; + + float get_setup_priority() const override; + + void set_model(Model model) { this->model_ = model; } + void set_offset(float offset_x, float offset_y, float offset_z); + void set_range(Range range) { this->range_ = range; } + void set_bandwidth(Bandwidth bandwidth) { this->bandwidth_ = bandwidth; } + void set_resolution(Resolution resolution) { this->resolution_ = resolution; } + void set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy); + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(tap) + SUB_BINARY_SENSOR(double_tap) + SUB_BINARY_SENSOR(active) +#endif + +#ifdef USE_SENSOR + SUB_SENSOR(acceleration_x) + SUB_SENSOR(acceleration_y) + SUB_SENSOR(acceleration_z) +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(orientation_xy) + SUB_TEXT_SENSOR(orientation_z) +#endif + + Trigger<> *get_tap_trigger() { return &this->tap_trigger_; } + Trigger<> *get_double_tap_trigger() { return &this->double_tap_trigger_; } + Trigger<> *get_orientation_trigger() { return &this->orientation_trigger_; } + Trigger<> *get_freefall_trigger() { return &this->freefall_trigger_; } + Trigger<> *get_active_trigger() { return &this->active_trigger_; } + + protected: + Model model_{Model::MSA311}; + + PowerMode power_mode_{PowerMode::NORMAL}; + DataRate data_rate_{DataRate::ODR_250HZ}; + Bandwidth bandwidth_{Bandwidth::BW_250HZ}; + Range range_{Range::RANGE_2G}; + Resolution resolution_{Resolution::RES_14BIT}; + float offset_x_, offset_y_, offset_z_; // in m/s² + RegSwapPolarity swap_; + + struct { + int scale_factor_exp; + uint8_t accel_data_width; + } device_params_{}; + + struct { + int16_t lsb_x, lsb_y, lsb_z; + float x, y, z; + } data_{}; + + struct { + RegMotionInterrupt motion_int; + RegOrientationStatus orientation; + RegOrientationStatus orientation_old; + + uint32_t last_tap_ms{0}; + uint32_t last_double_tap_ms{0}; + uint32_t last_action_ms{0}; + + bool never_published{true}; + } status_{}; + + void setup_odr_(DataRate rate); + void setup_power_mode_bandwidth_(PowerMode power_mode, Bandwidth bandwidth); + void setup_range_resolution_(Range range, Resolution resolution); + void setup_offset_(float offset_x, float offset_y, float offset_z); + + bool read_data_(); + bool read_motion_status_(); + + int64_t twos_complement_(uint64_t value, uint8_t bits); + + // + // Actons / Triggers + // + Trigger<> tap_trigger_; + Trigger<> double_tap_trigger_; + Trigger<> orientation_trigger_; + Trigger<> freefall_trigger_; + Trigger<> active_trigger_; + + void process_motions_(RegMotionInterrupt old); +}; + +} // namespace msa3xx +} // namespace esphome diff --git a/esphome/components/msa3xx/sensor.py b/esphome/components/msa3xx/sensor.py new file mode 100644 index 0000000000..63f050fa05 --- /dev/null +++ b/esphome/components/msa3xx/sensor.py @@ -0,0 +1,42 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCELERATION_X, + CONF_ACCELERATION_Y, + CONF_ACCELERATION_Z, + CONF_NAME, + ICON_BRIEFCASE_DOWNLOAD, + STATE_CLASS_MEASUREMENT, + UNIT_METER_PER_SECOND_SQUARED, +) + +from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["msa3xx"] + +ACCELERATION_SENSORS = (CONF_ACCELERATION_X, CONF_ACCELERATION_Y, CONF_ACCELERATION_Z) + +accel_schema = cv.maybe_simple_value( + sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND_SQUARED, + icon=ICON_BRIEFCASE_DOWNLOAD, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + key=CONF_NAME, +) + + +CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( + {cv.Optional(sensor): accel_schema for sensor in ACCELERATION_SENSORS} +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_MSA3XX_ID]) + for accel_key in ACCELERATION_SENSORS: + if accel_key in config: + sens = await sensor.new_sensor(config[accel_key]) + cg.add(getattr(hub, f"set_{accel_key}_sensor")(sens)) diff --git a/esphome/components/msa3xx/text_sensor.py b/esphome/components/msa3xx/text_sensor.py new file mode 100644 index 0000000000..c53a4aa139 --- /dev/null +++ b/esphome/components/msa3xx/text_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME + +from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["msa3xx"] + +CONF_ORIENTATION_XY = "orientation_xy" +CONF_ORIENTATION_Z = "orientation_z" +ICON_SCREEN_ROTATION = "mdi:screen-rotation" + +ORIENTATION_SENSORS = (CONF_ORIENTATION_XY, CONF_ORIENTATION_Z) + +CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( + { + cv.Optional(sensor): cv.maybe_simple_value( + text_sensor.text_sensor_schema(icon=ICON_SCREEN_ROTATION), + key=CONF_NAME, + ) + for sensor in ORIENTATION_SENSORS + } +) + + +async def setup_conf(config, key, hub): + if sensor_config := config.get(key): + var = await text_sensor.new_text_sensor(sensor_config) + cg.add(getattr(hub, f"set_{key}_text_sensor")(var)) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_MSA3XX_ID]) + + for key in ORIENTATION_SENSORS: + await setup_conf(config, key, hub) diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 1fe74dfcb5..ab2c7a5496 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -1,15 +1,17 @@ -import esphome.codegen as cg -import esphome.config_validation as cv from esphome import pins +import esphome.codegen as cg from esphome.components import display +import esphome.config_validation as cv from esphome.const import ( - CONF_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, CONF_BRIGHTNESS, CONF_CONTRAST, + CONF_EXTERNAL_VCC, CONF_INVERT, + CONF_LAMBDA, + CONF_MODEL, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_RESET_PIN, ) ssd1306_base_ns = cg.esphome_ns.namespace("ssd1306_base") @@ -18,8 +20,6 @@ SSD1306Model = ssd1306_base_ns.enum("SSD1306Model") CONF_FLIP_X = "flip_x" CONF_FLIP_Y = "flip_y" -CONF_OFFSET_X = "offset_x" -CONF_OFFSET_Y = "offset_y" MODELS = { "SSD1306_128X32": SSD1306Model.SSD1306_MODEL_128_32, diff --git a/esphome/const.py b/esphome/const.py index a0e0bea0c8..11494a0975 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -546,6 +546,9 @@ CONF_OFF_SPEED_CYCLE = "off_speed_cycle" CONF_OFFSET = "offset" CONF_OFFSET_HEIGHT = "offset_height" CONF_OFFSET_WIDTH = "offset_width" +CONF_OFFSET_X = "offset_x" +CONF_OFFSET_Y = "offset_y" +CONF_OFFSET_Z = "offset_z" CONF_ON = "on" CONF_ON_BLE_ADVERTISE = "on_ble_advertise" CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE = "on_ble_manufacturer_data_advertise" diff --git a/tests/components/msa3xx/common.yaml b/tests/components/msa3xx/common.yaml new file mode 100644 index 0000000000..8de6a8a89a --- /dev/null +++ b/tests/components/msa3xx/common.yaml @@ -0,0 +1,48 @@ +msa3xx: + i2c_id: i2c_msa3xx + type: msa301 + range: 4G + resolution: 14 + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: msa3xx + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: msa3xx + orientation_xy: Orientation XY + orientation_z: Orientation Z + +binary_sensor: + - platform: msa3xx + tap: Single tap + double_tap: + name: Double tap + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/msa3xx/test.esp32-ard.yaml b/tests/components/msa3xx/test.esp32-ard.yaml new file mode 100644 index 0000000000..7202e7b9bf --- /dev/null +++ b/tests/components/msa3xx/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO16 + sda: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/msa3xx/test.esp32-c3-ard.yaml b/tests/components/msa3xx/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..b972ce8cdb --- /dev/null +++ b/tests/components/msa3xx/test.esp32-c3-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO5 + sda: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/msa3xx/test.esp32-c3-idf.yaml b/tests/components/msa3xx/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b972ce8cdb --- /dev/null +++ b/tests/components/msa3xx/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO5 + sda: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/msa3xx/test.esp32-idf.yaml b/tests/components/msa3xx/test.esp32-idf.yaml new file mode 100644 index 0000000000..7202e7b9bf --- /dev/null +++ b/tests/components/msa3xx/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO16 + sda: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/msa3xx/test.esp8266-ard.yaml b/tests/components/msa3xx/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b972ce8cdb --- /dev/null +++ b/tests/components/msa3xx/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO5 + sda: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/msa3xx/test.rp2040-ard.yaml b/tests/components/msa3xx/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b972ce8cdb --- /dev/null +++ b/tests/components/msa3xx/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_msa3xx + scl: GPIO5 + sda: GPIO4 + +<<: !include common.yaml