Add support for Sensirion SPS30 Particulate Matter sensors (#891)

* Add support for Sensirion SPS30 Particulate Matter sensors

* Remove blocking of the main thread on initialization;
Improve wording on the debug messages;
Add robustness in re-initialization of reconnected or replaced sensors;

* Fix code formatting;


Co-authored-by: Nad <valordk@github>
This commit is contained in:
Nad 2019-12-04 12:34:10 +01:00 committed by Otto Winter
parent 0f406c38eb
commit 7f895abc24
6 changed files with 453 additions and 0 deletions

View file

View file

@ -0,0 +1,82 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import i2c, sensor
from esphome.const import CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, \
CONF_PMC_0_5, CONF_PMC_1_0, CONF_PMC_2_5, CONF_PMC_4_0, CONF_PMC_10_0, CONF_PM_SIZE, \
UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_COUNTS_PER_CUBIC_METER, UNIT_MICROMETER, \
ICON_CHEMICAL_WEAPON, ICON_COUNTER, ICON_RULER
DEPENDENCIES = ['i2c']
sps30_ns = cg.esphome_ns.namespace('sps30')
SPS30Component = sps30_ns.class_('SPS30Component', cg.PollingComponent, i2c.I2CDevice)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(SPS30Component),
cv.Optional(CONF_PM_1_0): sensor.sensor_schema(UNIT_MICROGRAMS_PER_CUBIC_METER,
ICON_CHEMICAL_WEAPON, 2),
cv.Optional(CONF_PM_2_5): sensor.sensor_schema(UNIT_MICROGRAMS_PER_CUBIC_METER,
ICON_CHEMICAL_WEAPON, 2),
cv.Optional(CONF_PM_4_0): sensor.sensor_schema(UNIT_MICROGRAMS_PER_CUBIC_METER,
ICON_CHEMICAL_WEAPON, 2),
cv.Optional(CONF_PM_10_0): sensor.sensor_schema(UNIT_MICROGRAMS_PER_CUBIC_METER,
ICON_CHEMICAL_WEAPON, 2),
cv.Optional(CONF_PMC_0_5): sensor.sensor_schema(UNIT_COUNTS_PER_CUBIC_METER,
ICON_COUNTER, 2),
cv.Optional(CONF_PMC_1_0): sensor.sensor_schema(UNIT_COUNTS_PER_CUBIC_METER,
ICON_COUNTER, 2),
cv.Optional(CONF_PMC_2_5): sensor.sensor_schema(UNIT_COUNTS_PER_CUBIC_METER,
ICON_COUNTER, 2),
cv.Optional(CONF_PMC_4_0): sensor.sensor_schema(UNIT_COUNTS_PER_CUBIC_METER,
ICON_COUNTER, 2),
cv.Optional(CONF_PMC_10_0): sensor.sensor_schema(UNIT_COUNTS_PER_CUBIC_METER,
ICON_COUNTER, 2),
cv.Optional(CONF_PM_SIZE): sensor.sensor_schema(UNIT_MICROMETER,
ICON_RULER, 0),
}).extend(cv.polling_component_schema('60s')).extend(i2c.i2c_device_schema(0x69))
def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
yield cg.register_component(var, config)
yield i2c.register_i2c_device(var, config)
if CONF_PM_1_0 in config:
sens = yield sensor.new_sensor(config[CONF_PM_1_0])
cg.add(var.set_pm_1_0_sensor(sens))
if CONF_PM_2_5 in config:
sens = yield sensor.new_sensor(config[CONF_PM_2_5])
cg.add(var.set_pm_2_5_sensor(sens))
if CONF_PM_4_0 in config:
sens = yield sensor.new_sensor(config[CONF_PM_4_0])
cg.add(var.set_pm_4_0_sensor(sens))
if CONF_PM_10_0 in config:
sens = yield sensor.new_sensor(config[CONF_PM_10_0])
cg.add(var.set_pm_10_0_sensor(sens))
if CONF_PMC_0_5 in config:
sens = yield sensor.new_sensor(config[CONF_PMC_0_5])
cg.add(var.set_pmc_0_5_sensor(sens))
if CONF_PMC_1_0 in config:
sens = yield sensor.new_sensor(config[CONF_PMC_1_0])
cg.add(var.set_pmc_1_0_sensor(sens))
if CONF_PMC_2_5 in config:
sens = yield sensor.new_sensor(config[CONF_PMC_2_5])
cg.add(var.set_pmc_2_5_sensor(sens))
if CONF_PMC_4_0 in config:
sens = yield sensor.new_sensor(config[CONF_PMC_4_0])
cg.add(var.set_pmc_4_0_sensor(sens))
if CONF_PMC_10_0 in config:
sens = yield sensor.new_sensor(config[CONF_PMC_10_0])
cg.add(var.set_pmc_10_0_sensor(sens))
if CONF_PM_SIZE in config:
sens = yield sensor.new_sensor(config[CONF_PM_SIZE])
cg.add(var.set_pm_size_sensor(sens))

View file

@ -0,0 +1,268 @@
#include "sps30.h"
#include "esphome/core/log.h"
namespace esphome {
namespace sps30 {
static const char *TAG = "sps30";
static const uint16_t SPS30_CMD_GET_ARTICLE_CODE = 0xD025;
static const uint16_t SPS30_CMD_GET_SERIAL_NUMBER = 0xD033;
static const uint16_t SPS30_CMD_GET_FIRMWARE_VERSION = 0xD100;
static const uint16_t SPS30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0010;
static const uint16_t SPS30_CMD_START_CONTINUOUS_MEASUREMENTS_ARG = 0x0300;
static const uint16_t SPS30_CMD_GET_DATA_READY_STATUS = 0x0202;
static const uint16_t SPS30_CMD_READ_MEASUREMENT = 0x0300;
static const uint16_t SPS30_CMD_STOP_MEASUREMENTS = 0x0104;
static const uint16_t SPS30_CMD_SET_AUTOMATIC_CLEANING_INTERVAL_SECONDS = 0x8004;
static const uint16_t SPS30_CMD_START_FAN_CLEANING = 0x5607;
static const uint16_t SPS30_CMD_SOFT_RESET = 0xD304;
static const size_t SERIAL_NUMBER_LENGTH = 8;
static const uint8_t MAX_SKIPPED_DATA_CYCLES_BEFORE_ERROR = 5;
void SPS30Component::setup() {
ESP_LOGCONFIG(TAG, "Setting up sps30...");
this->write_command_(SPS30_CMD_SOFT_RESET);
/// Deferred Sensor initialization
this->set_timeout(500, [this]() {
/// Firmware version identification
if (!this->write_command_(SPS30_CMD_GET_FIRMWARE_VERSION)) {
this->error_code_ = FIRMWARE_VERSION_REQUEST_FAILED;
this->mark_failed();
return;
}
uint16_t raw_firmware_version[4];
if (!this->read_data_(raw_firmware_version, 4)) {
this->error_code_ = FIRMWARE_VERSION_READ_FAILED;
this->mark_failed();
return;
}
ESP_LOGD(TAG, " Firmware version v%0d.%02d", (raw_firmware_version[0] >> 8),
uint16_t(raw_firmware_version[0] & 0xFF));
/// Serial number identification
if (!this->write_command_(SPS30_CMD_GET_SERIAL_NUMBER)) {
this->error_code_ = SERIAL_NUMBER_REQUEST_FAILED;
this->mark_failed();
return;
}
uint16_t raw_serial_number[8];
if (!this->read_data_(raw_serial_number, 8)) {
this->error_code_ = SERIAL_NUMBER_READ_FAILED;
this->mark_failed();
return;
}
for (size_t i = 0; i < 8; ++i) {
this->serial_number_[i * 2] = static_cast<char>(raw_serial_number[i] >> 8);
this->serial_number_[i * 2 + 1] = uint16_t(uint16_t(raw_serial_number[i] & 0xFF));
}
ESP_LOGD(TAG, " Serial Number: '%s'", this->serial_number_);
this->start_continuous_measurement_();
});
}
void SPS30Component::dump_config() {
ESP_LOGCONFIG(TAG, "sps30:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
switch (this->error_code_) {
case COMMUNICATION_FAILED:
ESP_LOGW(TAG, "Communication failed! Is the sensor connected?");
break;
case MEASUREMENT_INIT_FAILED:
ESP_LOGW(TAG, "Measurement Initialization failed!");
break;
case SERIAL_NUMBER_REQUEST_FAILED:
ESP_LOGW(TAG, "Unable to request sensor serial number");
break;
case SERIAL_NUMBER_READ_FAILED:
ESP_LOGW(TAG, "Unable to read sensor serial number");
break;
case FIRMWARE_VERSION_REQUEST_FAILED:
ESP_LOGW(TAG, "Unable to request sensor firmware version");
break;
case FIRMWARE_VERSION_READ_FAILED:
ESP_LOGW(TAG, "Unable to read sensor firmware version");
break;
default:
ESP_LOGW(TAG, "Unknown setup error!");
break;
}
}
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG, " Serial Number: '%s'", this->serial_number_);
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
LOG_SENSOR(" ", "PM4", this->pm_4_0_sensor_);
LOG_SENSOR(" ", "PM10", this->pm_10_0_sensor_);
}
void SPS30Component::update() {
/// Check if warning flag active (sensor reconnected?)
if (this->status_has_warning()) {
ESP_LOGD(TAG, "Trying to reconnect the sensor...");
if (this->write_command_(SPS30_CMD_SOFT_RESET)) {
ESP_LOGD(TAG, "Sensor has soft-reset successfully. Waiting for reconnection in 500ms...");
this->set_timeout(500, [this]() {
this->start_continuous_measurement_();
/// Sensor restarted and reading attempt made next cycle
this->status_clear_warning();
this->skipped_data_read_cycles_ = 0;
ESP_LOGD(TAG, "Sensor reconnected successfully. Resuming continuous measurement!");
});
} else {
ESP_LOGD(TAG, "Sensor soft-reset failed. Is the sensor offline?");
}
return;
}
/// Check if measurement is ready before reading the value
if (!this->write_command_(SPS30_CMD_GET_DATA_READY_STATUS)) {
this->status_set_warning();
return;
}
uint16_t raw_read_status[1];
if (!this->read_data_(raw_read_status, 1) || raw_read_status[0] == 0x00) {
ESP_LOGD(TAG, "Sensor measurement not ready yet.");
this->skipped_data_read_cycles_++;
/// The following logic is required to address the cases when a sensor is quickly replaced before it's marked
/// as failed so that new sensor is eventually forced to be reinitialized for continuous measurement.
if (this->skipped_data_read_cycles_ > MAX_SKIPPED_DATA_CYCLES_BEFORE_ERROR) {
ESP_LOGD(TAG, "Sensor exceeded max allowed attempts. Sensor communication will be reinitialized.");
this->status_set_warning();
}
return;
}
if (!this->write_command_(SPS30_CMD_READ_MEASUREMENT)) {
ESP_LOGW(TAG, "Error reading measurement status!");
this->status_set_warning();
return;
}
this->set_timeout(50, [this]() {
uint16_t raw_data[20];
if (!this->read_data_(raw_data, 20)) {
ESP_LOGW(TAG, "Error reading measurement data!");
this->status_set_warning();
return;
}
union uint32_float_t {
uint32_t uint32;
float value;
};
/// Reading and converting Mass concentration
uint32_float_t pm_1_0{.uint32 = (((uint32_t(raw_data[0])) << 16) | (uint32_t(raw_data[1])))};
uint32_float_t pm_2_5{.uint32 = (((uint32_t(raw_data[2])) << 16) | (uint32_t(raw_data[3])))};
uint32_float_t pm_4_0{.uint32 = (((uint32_t(raw_data[4])) << 16) | (uint32_t(raw_data[5])))};
uint32_float_t pm_10_0{.uint32 = (((uint32_t(raw_data[6])) << 16) | (uint32_t(raw_data[7])))};
/// Reading and converting Number concentration
uint32_float_t pmc_0_5{.uint32 = (((uint32_t(raw_data[8])) << 16) | (uint32_t(raw_data[9])))};
uint32_float_t pmc_1_0{.uint32 = (((uint32_t(raw_data[10])) << 16) | (uint32_t(raw_data[11])))};
uint32_float_t pmc_2_5{.uint32 = (((uint32_t(raw_data[12])) << 16) | (uint32_t(raw_data[13])))};
uint32_float_t pmc_4_0{.uint32 = (((uint32_t(raw_data[14])) << 16) | (uint32_t(raw_data[15])))};
uint32_float_t pmc_10_0{.uint32 = (((uint32_t(raw_data[16])) << 16) | (uint32_t(raw_data[17])))};
/// Reading and converting Typical size
uint32_float_t pm_size{.uint32 = (((uint32_t(raw_data[18])) << 16) | (uint32_t(raw_data[19])))};
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0.value);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5.value);
if (this->pm_4_0_sensor_ != nullptr)
this->pm_4_0_sensor_->publish_state(pm_4_0.value);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0.value);
if (this->pmc_0_5_sensor_ != nullptr)
this->pmc_0_5_sensor_->publish_state(pmc_0_5.value);
if (this->pmc_1_0_sensor_ != nullptr)
this->pmc_1_0_sensor_->publish_state(pmc_1_0.value);
if (this->pmc_2_5_sensor_ != nullptr)
this->pmc_2_5_sensor_->publish_state(pmc_2_5.value);
if (this->pmc_4_0_sensor_ != nullptr)
this->pmc_4_0_sensor_->publish_state(pmc_4_0.value);
if (this->pmc_10_0_sensor_ != nullptr)
this->pmc_10_0_sensor_->publish_state(pmc_10_0.value);
if (this->pm_size_sensor_ != nullptr)
this->pm_size_sensor_->publish_state(pm_size.value);
this->status_clear_warning();
this->skipped_data_read_cycles_ = 0;
});
}
bool SPS30Component::write_command_(uint16_t command) {
// Warning ugly, trick the I2Ccomponent base by setting register to the first 8 bit.
return this->write_byte(command >> 8, command & 0xFF);
}
uint8_t SPS30Component::sht_crc_(uint8_t data1, uint8_t data2) {
uint8_t bit;
uint8_t crc = 0xFF;
crc ^= data1;
for (bit = 8; bit > 0; --bit) {
if (crc & 0x80)
crc = (crc << 1) ^ 0x131;
else
crc = (crc << 1);
}
crc ^= data2;
for (bit = 8; bit > 0; --bit) {
if (crc & 0x80)
crc = (crc << 1) ^ 0x131;
else
crc = (crc << 1);
}
return crc;
}
bool SPS30Component::start_continuous_measurement_() {
uint8_t data[4];
data[0] = SPS30_CMD_START_CONTINUOUS_MEASUREMENTS & 0xFF;
data[1] = 0x03;
data[2] = 0x00;
data[3] = sht_crc_(0x03, 0x00);
if (!this->write_bytes(SPS30_CMD_START_CONTINUOUS_MEASUREMENTS >> 8, data, 4)) {
ESP_LOGE(TAG, "Error initiating measurements");
return false;
}
return true;
}
bool SPS30Component::read_data_(uint16_t *data, uint8_t len) {
const uint8_t num_bytes = len * 3;
auto *buf = new uint8_t[num_bytes];
if (!this->parent_->raw_receive(this->address_, buf, num_bytes)) {
delete[](buf);
return false;
}
for (uint8_t i = 0; i < len; i++) {
const uint8_t j = 3 * i;
uint8_t crc = sht_crc_(buf[j], buf[j + 1]);
if (crc != buf[j + 2]) {
ESP_LOGE(TAG, "CRC8 Checksum invalid! 0x%02X != 0x%02X", buf[j + 2], crc);
delete[](buf);
return false;
}
data[i] = (buf[j] << 8) | buf[j + 1];
}
delete[](buf);
return true;
}
} // namespace sps30
} // namespace esphome

View file

@ -0,0 +1,62 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome {
namespace sps30 {
/// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter
/// PM1.0, PM2.5, PM4, PM10 Air Quality sensors.
class SPS30Component : public PollingComponent, public i2c::I2CDevice {
public:
void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; }
void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; }
void set_pm_4_0_sensor(sensor::Sensor *pm_4_0) { pm_4_0_sensor_ = pm_4_0; }
void set_pm_10_0_sensor(sensor::Sensor *pm_10_0) { pm_10_0_sensor_ = pm_10_0; }
void set_pmc_0_5_sensor(sensor::Sensor *pmc_0_5) { pmc_0_5_sensor_ = pmc_0_5; }
void set_pmc_1_0_sensor(sensor::Sensor *pmc_1_0) { pmc_1_0_sensor_ = pmc_1_0; }
void set_pmc_2_5_sensor(sensor::Sensor *pmc_2_5) { pmc_2_5_sensor_ = pmc_2_5; }
void set_pmc_4_0_sensor(sensor::Sensor *pmc_4_0) { pmc_4_0_sensor_ = pmc_4_0; }
void set_pmc_10_0_sensor(sensor::Sensor *pmc_10_0) { pmc_10_0_sensor_ = pmc_10_0; }
void set_pm_size_sensor(sensor::Sensor *pm_size) { pm_size_sensor_ = pm_size; }
void setup() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
bool write_command_(uint16_t command);
bool read_data_(uint16_t *data, uint8_t len);
uint8_t sht_crc_(uint8_t data1, uint8_t data2);
char serial_number_[17] = {0}; /// Terminating NULL character
bool start_continuous_measurement_();
uint8_t skipped_data_read_cycles_ = 0;
enum ErrorCode {
COMMUNICATION_FAILED,
FIRMWARE_VERSION_REQUEST_FAILED,
FIRMWARE_VERSION_READ_FAILED,
SERIAL_NUMBER_REQUEST_FAILED,
SERIAL_NUMBER_READ_FAILED,
MEASUREMENT_INIT_FAILED,
UNKNOWN
} error_code_{UNKNOWN};
sensor::Sensor *pm_1_0_sensor_{nullptr};
sensor::Sensor *pm_2_5_sensor_{nullptr};
sensor::Sensor *pm_4_0_sensor_{nullptr};
sensor::Sensor *pm_10_0_sensor_{nullptr};
sensor::Sensor *pmc_0_5_sensor_{nullptr};
sensor::Sensor *pmc_1_0_sensor_{nullptr};
sensor::Sensor *pmc_2_5_sensor_{nullptr};
sensor::Sensor *pmc_4_0_sensor_{nullptr};
sensor::Sensor *pmc_10_0_sensor_{nullptr};
sensor::Sensor *pm_size_sensor_{nullptr};
};
} // namespace sps30
} // namespace esphome

View file

@ -329,6 +329,13 @@ CONF_PLATFORMIO_OPTIONS = 'platformio_options'
CONF_PM_1_0 = 'pm_1_0'
CONF_PM_10_0 = 'pm_10_0'
CONF_PM_2_5 = 'pm_2_5'
CONF_PM_4_0 = 'pm_4_0'
CONF_PM_SIZE = 'pm_size'
CONF_PMC_0_5 = 'pmc_0_5'
CONF_PMC_1_0 = 'pmc_1_0'
CONF_PMC_10_0 = 'pmc_10_0'
CONF_PMC_2_5 = 'pmc_2_5'
CONF_PMC_4_0 = 'pmc_4_0'
CONF_PORT = 'port'
CONF_POSITION = 'position'
CONF_POSITION_ACTION = 'position_action'
@ -503,6 +510,7 @@ ICON_BRIEFCASE_DOWNLOAD = 'mdi:briefcase-download'
ICON_BRIGHTNESS_5 = 'mdi:brightness-5'
ICON_CHECK_CIRCLE_OUTLINE = 'mdi:check-circle-outline'
ICON_CHEMICAL_WEAPON = 'mdi:chemical-weapon'
ICON_COUNTER = 'mdi:counter'
ICON_CURRENT_AC = 'mdi:current-ac'
ICON_EMPTY = ''
ICON_FLASH = 'mdi:flash'
@ -519,6 +527,7 @@ ICON_PULSE = 'mdi:pulse'
ICON_RADIATOR = 'mdi:radiator'
ICON_RESTART = 'mdi:restart'
ICON_ROTATE_RIGHT = 'mdi:rotate-right'
ICON_RULER = 'mdi:ruler'
ICON_SCALE = 'mdi:scale'
ICON_SCREEN_ROTATION = 'mdi:screen-rotation'
ICON_SIGN_DIRECTION = 'mdi:sign-direction'
@ -535,6 +544,7 @@ ICON_WIFI = 'mdi:wifi'
UNIT_AMPERE = 'A'
UNIT_CELSIUS = u'°C'
UNIT_COUNTS_PER_CUBIC_METER = u'#/m³'
UNIT_DECIBEL = 'dB'
UNIT_DECIBEL_MILLIWATT = 'dBm'
UNIT_DEGREE_PER_SECOND = u'°/s'
@ -550,6 +560,7 @@ UNIT_LUX = 'lx'
UNIT_METER = 'm'
UNIT_METER_PER_SECOND_SQUARED = u'm/s²'
UNIT_MICROGRAMS_PER_CUBIC_METER = u'µg/m³'
UNIT_MICROMETER = 'µm'
UNIT_MICROSIEMENS_PER_CENTIMETER = u'µS/cm'
UNIT_MICROTESLA = u'µT'
UNIT_OHM = u'Ω'

View file

@ -593,6 +593,36 @@ sensor:
accuracy_decimals: 1
address: 0x58
update_interval: 5s
- platform: sps30
pm_1_0:
name: "Workshop PM <1µm Weight concentration"
id: "workshop_PM_1_0"
pm_2_5:
name: "Workshop PM <2.5µm Weight concentration"
id: "workshop_PM_2_5"
pm_4_0:
name: "Workshop PM <4µm Weight concentration"
id: "workshop_PM_4_0"
pm_10_0:
name: "Workshop PM <10µm Weight concentration"
id: "workshop_PM_10_0"
pmc_0_5:
name: "Workshop PM <0.5µm Number concentration"
id: "workshop_PMC_0_5"
pmc_1_0:
name: "Workshop PM <1µm Number concentration"
id: "workshop_PMC_1_0"
pmc_2_5:
name: "Workshop PM <2.5µm Number concentration"
id: "workshop_PMC_2_5"
pmc_4_0:
name: "Workshop PM <4µm Number concentration"
id: "workshop_PMC_4_0"
pmc_10_0:
name: "Workshop PM <10µm Number concentration"
id: "workshop_PMC_10_0"
address: 0x69
update_interval: 10s
- platform: shtcx
temperature:
name: "Living Room Temperature 10"