From f456603c1b6e3428aa7204e7a58ca068b3a72353 Mon Sep 17 00:00:00 2001 From: Vincent Schmandt Date: Fri, 24 Nov 2023 05:31:07 +0100 Subject: [PATCH] Add ENS160 Sensor (#4243) Co-authored-by: Keith Burzinski Co-authored-by: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ens160/__init__.py | 1 + esphome/components/ens160/ens160.cpp | 321 ++++++++++++++++++++++++++ esphome/components/ens160/ens160.h | 60 +++++ esphome/components/ens160/sensor.py | 88 +++++++ tests/test3.1.yaml | 11 +- tests/test3.yaml | 1 - 7 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 esphome/components/ens160/__init__.py create mode 100644 esphome/components/ens160/ens160.cpp create mode 100644 esphome/components/ens160/ens160.h create mode 100644 esphome/components/ens160/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index dd1586d039..af23f679c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,7 @@ esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M esphome/components/ektf2232/* @jesserockz esphome/components/emc2101/* @ellull +esphome/components/ens160/* @vincentscode esphome/components/ens210/* @itn3rd77 esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @Rapsssito @jesserockz diff --git a/esphome/components/ens160/__init__.py b/esphome/components/ens160/__init__.py new file mode 100644 index 0000000000..d26770a89d --- /dev/null +++ b/esphome/components/ens160/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@vincentscode"] diff --git a/esphome/components/ens160/ens160.cpp b/esphome/components/ens160/ens160.cpp new file mode 100644 index 0000000000..c7a6ccbb73 --- /dev/null +++ b/esphome/components/ens160/ens160.cpp @@ -0,0 +1,321 @@ +// ENS160 sensor with I2C interface from ScioSense +// +// Datasheet: https://www.sciosense.com/wp-content/uploads/documents/SC-001224-DS-7-ENS160-Datasheet.pdf +// +// Implementation based on: +// https://github.com/sciosense/ENS160_driver + +#include "ens160.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome { +namespace ens160 { + +static const char *const TAG = "ens160"; + +static const uint8_t ENS160_BOOTING = 10; + +static const uint16_t ENS160_PART_ID = 0x0160; + +static const uint8_t ENS160_REG_PART_ID = 0x00; +static const uint8_t ENS160_REG_OPMODE = 0x10; +static const uint8_t ENS160_REG_CONFIG = 0x11; +static const uint8_t ENS160_REG_COMMAND = 0x12; +static const uint8_t ENS160_REG_TEMP_IN = 0x13; +static const uint8_t ENS160_REG_DATA_STATUS = 0x20; +static const uint8_t ENS160_REG_DATA_AQI = 0x21; +static const uint8_t ENS160_REG_DATA_TVOC = 0x22; +static const uint8_t ENS160_REG_DATA_ECO2 = 0x24; + +static const uint8_t ENS160_REG_GPR_READ_0 = 0x48; +static const uint8_t ENS160_REG_GPR_READ_4 = ENS160_REG_GPR_READ_0 + 4; + +static const uint8_t ENS160_COMMAND_NOP = 0x00; +static const uint8_t ENS160_COMMAND_CLRGPR = 0xCC; +static const uint8_t ENS160_COMMAND_GET_APPVER = 0x0E; + +static const uint8_t ENS160_OPMODE_RESET = 0xF0; +static const uint8_t ENS160_OPMODE_IDLE = 0x01; +static const uint8_t ENS160_OPMODE_STD = 0x02; + +static const uint8_t ENS160_DATA_STATUS_STATAS = 0x80; +static const uint8_t ENS160_DATA_STATUS_STATER = 0x40; +static const uint8_t ENS160_DATA_STATUS_VALIDITY = 0x0C; +static const uint8_t ENS160_DATA_STATUS_NEWDAT = 0x02; +static const uint8_t ENS160_DATA_STATUS_NEWGPR = 0x01; + +// helps remove reserved bits in aqi data register +static const uint8_t ENS160_DATA_AQI = 0x07; + +void ENS160Component::setup() { + ESP_LOGCONFIG(TAG, "Setting up ENS160..."); + + // check part_id + uint16_t part_id; + if (!this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2)) { + this->error_code_ = COMMUNICATION_FAILED; + this->mark_failed(); + return; + } + if (part_id != ENS160_PART_ID) { + this->error_code_ = INVALID_ID; + this->mark_failed(); + return; + } + + // set mode to reset + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + delay(ENS160_BOOTING); + + // check status + uint8_t status_value; + if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + + if (this->validity_flag_ == INVALID_OUTPUT) { + this->error_code_ = VALIDITY_INVALID; + this->mark_failed(); + return; + } + + // set mode to idle + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + // clear command + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_CLRGPR)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + + // read firmware version + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + uint8_t version_data[3]; + if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + this->firmware_ver_major_ = version_data[0]; + this->firmware_ver_minor_ = version_data[1]; + this->firmware_ver_build_ = version_data[2]; + + // set mode to standard + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + + // read opmode and check standard mode is achieved before finishing Setup + uint8_t op_mode; + if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + + if (op_mode != ENS160_OPMODE_STD) { + this->error_code_ = STD_OPMODE_FAILED; + this->mark_failed(); + return; + } +} + +void ENS160Component::update() { + uint8_t status_value, data_ready; + + if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { + ESP_LOGW(TAG, "Error reading status register"); + this->status_set_warning(); + return; + } + + // verbose status logging + ESP_LOGV(TAG, "Status: ENS160 STATAS bit 0x%x", + (ENS160_DATA_STATUS_STATAS & (status_value)) == ENS160_DATA_STATUS_STATAS); + ESP_LOGV(TAG, "Status: ENS160 STATER bit 0x%x", + (ENS160_DATA_STATUS_STATER & (status_value)) == ENS160_DATA_STATUS_STATER); + ESP_LOGV(TAG, "Status: ENS160 VALIDITY FLAG 0x%02x", (ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + ESP_LOGV(TAG, "Status: ENS160 NEWDAT bit 0x%x", + (ENS160_DATA_STATUS_NEWDAT & (status_value)) == ENS160_DATA_STATUS_NEWDAT); + ESP_LOGV(TAG, "Status: ENS160 NEWGPR bit 0x%x", + (ENS160_DATA_STATUS_NEWGPR & (status_value)) == ENS160_DATA_STATUS_NEWGPR); + + data_ready = ENS160_DATA_STATUS_NEWDAT & status_value; + this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + + switch (validity_flag_) { + case NORMAL_OPERATION: + if (data_ready != ENS160_DATA_STATUS_NEWDAT) { + ESP_LOGD(TAG, "ENS160 readings unavailable - Normal Operation but readings not ready"); + return; + } + break; + case INITIAL_STARTUP: + if (!this->initial_startup_) { + this->initial_startup_ = true; + ESP_LOGI(TAG, "ENS160 readings unavailable - 1 hour startup required after first power on"); + } + return; + case WARMING_UP: + if (!this->warming_up_) { + this->warming_up_ = true; + ESP_LOGI(TAG, "ENS160 readings not available yet - Warming up requires 3 minutes"); + this->send_env_data_(); + } + return; + case INVALID_OUTPUT: + ESP_LOGE(TAG, "ENS160 Invalid Status - No Invalid Output"); + this->status_set_warning(); + return; + } + + // read new data + uint16_t data_eco2; + if (!this->read_bytes(ENS160_REG_DATA_ECO2, reinterpret_cast(&data_eco2), 2)) { + ESP_LOGW(TAG, "Error reading eCO2 data register"); + this->status_set_warning(); + return; + } + if (this->co2_ != nullptr) { + this->co2_->publish_state(data_eco2); + } + + uint16_t data_tvoc; + if (!this->read_bytes(ENS160_REG_DATA_TVOC, reinterpret_cast(&data_tvoc), 2)) { + ESP_LOGW(TAG, "Error reading TVOC data register"); + this->status_set_warning(); + return; + } + if (this->tvoc_ != nullptr) { + this->tvoc_->publish_state(data_tvoc); + } + + uint8_t data_aqi; + if (!this->read_byte(ENS160_REG_DATA_AQI, &data_aqi)) { + ESP_LOGW(TAG, "Error reading AQI data register"); + this->status_set_warning(); + return; + } + if (this->aqi_ != nullptr) { + // remove reserved bits, just in case they are used in future + data_aqi = ENS160_DATA_AQI & data_aqi; + + this->aqi_->publish_state(data_aqi); + } + + this->status_clear_warning(); + + // set temperature and humidity compensation data + this->send_env_data_(); +} + +void ENS160Component::send_env_data_() { + if (this->temperature_ == nullptr && this->humidity_ == nullptr) + return; + + float temperature = NAN; + if (this->temperature_ != nullptr) + temperature = this->temperature_->state; + + if (std::isnan(temperature) || temperature < -40.0f || temperature > 85.0f) { + ESP_LOGW(TAG, "Invalid external temperature - compensation values not updated"); + return; + } else { + ESP_LOGV(TAG, "External temperature compensation: %.1f°C", temperature); + } + + float humidity = NAN; + if (this->humidity_ != nullptr) + humidity = this->humidity_->state; + + if (std::isnan(humidity) || humidity < 0.0f || humidity > 100.0f) { + ESP_LOGW(TAG, "Invalid external humidity - compensation values not updated"); + return; + } else { + ESP_LOGV(TAG, "External humidity compensation: %.1f%%", humidity); + } + + uint16_t t = (uint16_t) ((temperature + 273.15f) * 64.0f); + uint16_t h = (uint16_t) (humidity * 512.0f); + + uint8_t data[4]; + data[0] = t & 0xff; + data[1] = (t >> 8) & 0xff; + data[2] = h & 0xff; + data[3] = (h >> 8) & 0xff; + + if (!this->write_bytes(ENS160_REG_TEMP_IN, data, 4)) { + ESP_LOGE(TAG, "Error writing compensation values"); + this->status_set_warning(); + return; + } +} + +void ENS160Component::dump_config() { + ESP_LOGCONFIG(TAG, "ENS160:"); + + switch (this->error_code_) { + case COMMUNICATION_FAILED: + ESP_LOGE(TAG, "Communication failed! Is the sensor connected?"); + break; + case READ_FAILED: + ESP_LOGE(TAG, "Error reading from register"); + break; + case WRITE_FAILED: + ESP_LOGE(TAG, "Error writing to register"); + break; + case INVALID_ID: + ESP_LOGE(TAG, "Sensor reported an invalid ID. Is this a ENS160?"); + break; + case VALIDITY_INVALID: + ESP_LOGE(TAG, "Invalid Device Status - No valid output"); + break; + case STD_OPMODE_FAILED: + ESP_LOGE(TAG, "Device failed to achieve Standard Operating Mode"); + break; + case NONE: + ESP_LOGD(TAG, "Setup successful"); + break; + } + ESP_LOGI(TAG, "Firmware Version: %d.%d.%d", this->firmware_ver_major_, this->firmware_ver_minor_, + this->firmware_ver_build_); + + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "CO2 Sensor:", this->co2_); + LOG_SENSOR(" ", "TVOC Sensor:", this->tvoc_); + LOG_SENSOR(" ", "AQI Sensor:", this->aqi_); + + if (this->temperature_ != nullptr && this->humidity_ != nullptr) { + LOG_SENSOR(" ", " Temperature Compensation:", this->temperature_); + LOG_SENSOR(" ", " Humidity Compensation:", this->humidity_); + } else { + ESP_LOGCONFIG(TAG, " Compensation: Not configured"); + } +} + +} // namespace ens160 +} // namespace esphome diff --git a/esphome/components/ens160/ens160.h b/esphome/components/ens160/ens160.h new file mode 100644 index 0000000000..88bc8e3501 --- /dev/null +++ b/esphome/components/ens160/ens160.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace ens160 { + +class ENS160Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { + public: + void set_co2(sensor::Sensor *co2) { co2_ = co2; } + void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } + void set_aqi(sensor::Sensor *aqi) { aqi_ = aqi; } + + void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } + void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + void send_env_data_(); + + enum ErrorCode { + NONE = 0, + COMMUNICATION_FAILED, + INVALID_ID, + VALIDITY_INVALID, + READ_FAILED, + WRITE_FAILED, + STD_OPMODE_FAILED, + } error_code_{NONE}; + + enum ValidityFlag { + NORMAL_OPERATION = 0, + WARMING_UP, + INITIAL_STARTUP, + INVALID_OUTPUT, + } validity_flag_; + + bool warming_up_{false}; + bool initial_startup_{false}; + + uint8_t firmware_ver_major_{0}; + uint8_t firmware_ver_minor_{0}; + uint8_t firmware_ver_build_{0}; + + sensor::Sensor *co2_{nullptr}; + sensor::Sensor *tvoc_{nullptr}; + sensor::Sensor *aqi_{nullptr}; + + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *temperature_{nullptr}; +}; + +} // namespace ens160 +} // namespace esphome diff --git a/esphome/components/ens160/sensor.py b/esphome/components/ens160/sensor.py new file mode 100644 index 0000000000..55f0ff7b6f --- /dev/null +++ b/esphome/components/ens160/sensor.py @@ -0,0 +1,88 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import i2c, sensor +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, +) + +CODEOWNERS = ["@vincentscode"] +DEPENDENCIES = ["i2c"] + +ens160_ns = cg.esphome_ns.namespace("ens160") +ENS160Component = ens160_ns.class_( + "ENS160Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_AQI = "aqi" +CONF_COMPENSATION = "compensation" +UNIT_INDEX = "index" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ENS160Component), + cv.Required(CONF_ECO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Required(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Required(CONF_AQI): sensor.sensor_schema( + unit_of_measurement=UNIT_INDEX, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COMPENSATION): cv.Schema( + { + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor), + } + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x53)) +) + + +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) + + sens = await sensor.new_sensor(config[CONF_ECO2]) + cg.add(var.set_co2(sens)) + sens = await sensor.new_sensor(config[CONF_TVOC]) + cg.add(var.set_tvoc(sens)) + sens = await sensor.new_sensor(config[CONF_AQI]) + cg.add(var.set_aqi(sens)) + + if CONF_COMPENSATION in config: + compensation_config = config[CONF_COMPENSATION] + sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) + cg.add(var.set_temperature(sens)) + sens = await cg.get_variable(compensation_config[CONF_HUMIDITY]) + cg.add(var.set_humidity(sens)) diff --git a/tests/test3.1.yaml b/tests/test3.1.yaml index 151e53fd62..9000636f63 100644 --- a/tests/test3.1.yaml +++ b/tests/test3.1.yaml @@ -225,6 +225,13 @@ sensor: name: "ADE7953 Reactive Power B" update_interval: 1s + - platform: ens160 + eco2: + name: "ENS160 eCO2" + tvoc: + name: "ENS160 Total Volatile Organic Compounds" + aqi: + name: "ENS160 Air Quality Index" - platform: tmp102 name: TMP102 Temperature - platform: hm3301 @@ -424,7 +431,6 @@ switch: direction: BACKWARD id: test_motor - custom_component: lambda: |- auto s = new CustomComponent(); @@ -613,7 +619,6 @@ mcp23017: mcp23008: id: mcp23008_hub - light: - platform: hbridge name: Icicle Lights @@ -633,7 +638,6 @@ ttp229_bsf: sdo_pin: D2 scl_pin: D1 - display: - platform: max7219digit cs_pin: GPIO15 @@ -645,7 +649,6 @@ display: lambda: |- it.printdigit("hello"); - http_request: useragent: esphome/device timeout: 10s diff --git a/tests/test3.yaml b/tests/test3.yaml index 41ded7ee39..0a405a2841 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -480,7 +480,6 @@ sensor: name: PZEMDC Power energy: name: PZEMDC Energy - - platform: pmsx003 uart_id: uart_9 type: PMSX003