-
Notifications
You must be signed in to change notification settings - Fork 1
Add particle count and dallas probe #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8b13aff
83574ac
47d7605
fd7016c
7efad6c
80ce7a3
73f5ee6
991594d
9fb2a81
5905e97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # ESPHome custom component for SEN5x particle number concentration | ||
| # This component uses the undocumented I2C command 0x0413 to read | ||
| # both mass and number concentrations from the Sensirion SEN5x sensor | ||
| # | ||
| # EXPERIMENTAL - Based on Sensirion app note: | ||
| # "Sensirion_SEN5x_Read_Mass_and_Number_Concentrations.pdf" | ||
|
|
||
| CODEOWNERS = ["@apolloautomation"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| #include "sen5x_number.h" | ||
| #include "esphome/core/log.h" | ||
| #include "esphome/core/hal.h" | ||
|
|
||
| namespace esphome { | ||
| namespace sen5x_number { | ||
|
|
||
| static const char *const TAG = "sen5x_number"; | ||
|
|
||
| void SEN5xNumberConcentration::setup() { | ||
| ESP_LOGCONFIG(TAG, "Setting up SEN5x Number Concentration (EXPERIMENTAL)..."); | ||
| } | ||
|
|
||
| void SEN5xNumberConcentration::dump_config() { | ||
| ESP_LOGCONFIG(TAG, "SEN5x Number Concentration (EXPERIMENTAL):"); | ||
| LOG_I2C_DEVICE(this); | ||
| LOG_UPDATE_INTERVAL(this); | ||
| LOG_SENSOR(" ", "PM0.5 Number", this->pm_0_5_number_); | ||
| LOG_SENSOR(" ", "PM1.0 Number", this->pm_1_0_number_); | ||
| LOG_SENSOR(" ", "PM2.5 Number", this->pm_2_5_number_); | ||
| LOG_SENSOR(" ", "PM4.0 Number", this->pm_4_0_number_); | ||
| LOG_SENSOR(" ", "PM10.0 Number", this->pm_10_0_number_); | ||
| LOG_SENSOR(" ", "Typical Particle Size", this->typical_particle_size_); | ||
| } | ||
|
|
||
| void SEN5xNumberConcentration::update() { | ||
| if (!this->read_data_()) { | ||
| ESP_LOGW(TAG, "Failed to read number concentration data"); | ||
| this->status_set_warning(); | ||
| return; | ||
| } | ||
| this->status_clear_warning(); | ||
| } | ||
|
|
||
| uint8_t SEN5xNumberConcentration::sht_crc_(uint8_t data1, uint8_t data2) { | ||
| // CRC-8 formula from Sensirion datasheet | ||
| uint8_t crc = 0xFF; | ||
| uint8_t data[2] = {data1, data2}; | ||
|
|
||
| for (uint8_t i = 0; i < 2; i++) { | ||
| crc ^= data[i]; | ||
| for (uint8_t bit = 8; bit > 0; --bit) { | ||
| if (crc & 0x80) { | ||
| crc = (crc << 1) ^ 0x31; | ||
| } else { | ||
| crc = (crc << 1); | ||
| } | ||
| } | ||
| } | ||
| return crc; | ||
| } | ||
|
|
||
| bool SEN5xNumberConcentration::read_data_() { | ||
| // Send command 0x0413 (Read Measured Mass and Number Concentrations) | ||
| uint8_t command[2] = { | ||
| static_cast<uint8_t>(SEN5X_CMD_READ_MASS_NUMBER >> 8), | ||
| static_cast<uint8_t>(SEN5X_CMD_READ_MASS_NUMBER & 0xFF) | ||
| }; | ||
|
|
||
| if (this->write(command, 2) != i2c::ERROR_OK) { | ||
| ESP_LOGW(TAG, "Failed to send read command"); | ||
| return false; | ||
| } | ||
|
|
||
| // Wait for measurement to be ready (typical 20ms for SEN5x) | ||
| delay(20); | ||
|
|
||
| // Read response: 45 bytes (15 values x 3 bytes each: 2 data + 1 CRC) | ||
| uint8_t response[SEN5X_RESPONSE_LENGTH]; | ||
| if (this->read(response, SEN5X_RESPONSE_LENGTH) != i2c::ERROR_OK) { | ||
| ESP_LOGW(TAG, "Failed to read response data"); | ||
| return false; | ||
| } | ||
|
|
||
| // Parse response with CRC validation | ||
| // Response format (all values are uint16_t with CRC): | ||
| // Bytes 0-2: PM1.0 mass concentration (µg/m³) - we ignore this | ||
| // Bytes 3-5: PM2.5 mass concentration (µg/m³) - we ignore this | ||
| // Bytes 6-8: PM4.0 mass concentration (µg/m³) - we ignore this | ||
| // Bytes 9-11: PM10.0 mass concentration (µg/m³) - we ignore this | ||
| // Bytes 12-14: PM0.5 number concentration (#/cm³) - NEW | ||
| // Bytes 15-17: PM1.0 number concentration (#/cm³) - NEW | ||
| // Bytes 18-20: PM2.5 number concentration (#/cm³) - NEW | ||
| // Bytes 21-23: PM4.0 number concentration (#/cm³) - NEW | ||
| // Bytes 24-26: PM10.0 number concentration (#/cm³) - NEW | ||
| // Bytes 27-29: Typical particle size (µm) - NEW | ||
| // Bytes 30-44: Additional reserved values | ||
|
|
||
| auto parse_value = [&](uint8_t offset) -> float { | ||
| uint8_t msb = response[offset]; | ||
| uint8_t lsb = response[offset + 1]; | ||
| uint8_t crc = response[offset + 2]; | ||
|
|
||
| if (crc != this->sht_crc_(msb, lsb)) { | ||
| ESP_LOGW(TAG, "CRC check failed for byte offset %d", offset); | ||
| return NAN; | ||
| } | ||
|
|
||
| uint16_t raw = (static_cast<uint16_t>(msb) << 8) | lsb; | ||
| return static_cast<float>(raw); | ||
| }; | ||
|
|
||
| // Extract number concentrations (converting from #/cm³ to #/m³) | ||
| // Note: 1 #/cm³ = 1,000,000 #/m³ | ||
| if (this->pm_0_5_number_ != nullptr) { | ||
| float value = parse_value(12) / 10.0f; // Scaled by 10 per datasheet | ||
| if (!std::isnan(value)) { | ||
| this->pm_0_5_number_->publish_state(value * 1e6f); // Convert to #/m³ | ||
| } | ||
| } | ||
|
|
||
| if (this->pm_1_0_number_ != nullptr) { | ||
| float value = parse_value(15) / 10.0f; | ||
| if (!std::isnan(value)) { | ||
| this->pm_1_0_number_->publish_state(value * 1e6f); | ||
| } | ||
| } | ||
|
|
||
| if (this->pm_2_5_number_ != nullptr) { | ||
| float value = parse_value(18) / 10.0f; | ||
| if (!std::isnan(value)) { | ||
| this->pm_2_5_number_->publish_state(value * 1e6f); | ||
| } | ||
| } | ||
|
|
||
| if (this->pm_4_0_number_ != nullptr) { | ||
| float value = parse_value(21) / 10.0f; | ||
| if (!std::isnan(value)) { | ||
| this->pm_4_0_number_->publish_state(value * 1e6f); | ||
| } | ||
| } | ||
|
|
||
| if (this->pm_10_0_number_ != nullptr) { | ||
| float value = parse_value(24) / 10.0f; | ||
| if (!std::isnan(value)) { | ||
| this->pm_10_0_number_->publish_state(value * 1e6f); | ||
| } | ||
| } | ||
|
|
||
| // Typical particle size in µm | ||
| if (this->typical_particle_size_ != nullptr) { | ||
| float value = parse_value(27) / 1000.0f; // Scaled by 1000 per datasheet | ||
| if (!std::isnan(value)) { | ||
| this->typical_particle_size_->publish_state(value); | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| } // namespace sen5x_number | ||
| } // namespace esphome | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| #pragma once | ||
|
|
||
| #include "esphome/core/component.h" | ||
| #include "esphome/components/sensor/sensor.h" | ||
| #include "esphome/components/i2c/i2c.h" | ||
|
|
||
| namespace esphome { | ||
| namespace sen5x_number { | ||
|
|
||
| // EXPERIMENTAL: Custom component for SEN5x particle NUMBER concentration | ||
| // Uses undocumented I2C command 0x0413 to read both mass and number concentrations | ||
| // Reference: Sensirion_SEN5x_Read_Mass_and_Number_Concentrations.pdf | ||
|
|
||
| class SEN5xNumberConcentration : public PollingComponent, public i2c::I2CDevice { | ||
| public: | ||
| void setup() override; | ||
| void update() override; | ||
| void dump_config() override; | ||
| float get_setup_priority() const override { return setup_priority::DATA; } | ||
|
|
||
| void set_pm_0_5_number_sensor(sensor::Sensor *pm_0_5_number) { pm_0_5_number_ = pm_0_5_number; } | ||
| void set_pm_1_0_number_sensor(sensor::Sensor *pm_1_0_number) { pm_1_0_number_ = pm_1_0_number; } | ||
| void set_pm_2_5_number_sensor(sensor::Sensor *pm_2_5_number) { pm_2_5_number_ = pm_2_5_number; } | ||
| void set_pm_4_0_number_sensor(sensor::Sensor *pm_4_0_number) { pm_4_0_number_ = pm_4_0_number; } | ||
| void set_pm_10_0_number_sensor(sensor::Sensor *pm_10_0_number) { pm_10_0_number_ = pm_10_0_number; } | ||
| void set_typical_particle_size_sensor(sensor::Sensor *typical_size) { typical_particle_size_ = typical_size; } | ||
|
|
||
| protected: | ||
| sensor::Sensor *pm_0_5_number_{nullptr}; | ||
| sensor::Sensor *pm_1_0_number_{nullptr}; | ||
| sensor::Sensor *pm_2_5_number_{nullptr}; | ||
| sensor::Sensor *pm_4_0_number_{nullptr}; | ||
| sensor::Sensor *pm_10_0_number_{nullptr}; | ||
| sensor::Sensor *typical_particle_size_{nullptr}; | ||
|
|
||
| bool read_data_(); | ||
| uint8_t sht_crc_(uint8_t data1, uint8_t data2); | ||
|
|
||
| // SEN5x I2C command: Read Measured Mass and Number Concentrations | ||
| static const uint16_t SEN5X_CMD_READ_MASS_NUMBER = 0x0413; | ||
| static const uint8_t SEN5X_RESPONSE_LENGTH = 45; // 15 values x 3 bytes (2 data + 1 CRC) | ||
| }; | ||
|
|
||
| } // namespace sen5x_number | ||
| } // namespace esphome |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import esphome.codegen as cg | ||
| import esphome.config_validation as cv | ||
| from esphome.components import i2c, sensor | ||
| from esphome.const import ( | ||
| CONF_ID, | ||
| DEVICE_CLASS_PM25, | ||
| STATE_CLASS_MEASUREMENT, | ||
| ICON_CHEMICAL_WEAPON, | ||
| ) | ||
|
|
||
| DEPENDENCIES = ["i2c"] | ||
| CODEOWNERS = ["@apolloautomation"] | ||
|
|
||
| sen5x_number_ns = cg.esphome_ns.namespace("sen5x_number") | ||
| SEN5xNumberConcentration = sen5x_number_ns.class_( | ||
| "SEN5xNumberConcentration", cg.PollingComponent, i2c.I2CDevice | ||
| ) | ||
|
|
||
| CONF_PM_0_5_NUMBER = "pm_0_5_number" | ||
| CONF_PM_1_0_NUMBER = "pm_1_0_number" | ||
| CONF_PM_2_5_NUMBER = "pm_2_5_number" | ||
| CONF_PM_4_0_NUMBER = "pm_4_0_number" | ||
| CONF_PM_10_0_NUMBER = "pm_10_0_number" | ||
| CONF_TYPICAL_PARTICLE_SIZE = "typical_particle_size" | ||
|
|
||
| # Unit for particle number concentration | ||
| UNIT_NUMBER_PER_CUBIC_METER = "#/m³" | ||
| UNIT_MICROMETER = "µm" | ||
|
|
||
| CONFIG_SCHEMA = ( | ||
| cv.Schema( | ||
| { | ||
| cv.GenerateID(): cv.declare_id(SEN5xNumberConcentration), | ||
| cv.Optional(CONF_PM_0_5_NUMBER): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=0, | ||
| device_class=DEVICE_CLASS_PM25, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
| ), | ||
| cv.Optional(CONF_PM_1_0_NUMBER): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=0, | ||
| device_class=DEVICE_CLASS_PM25, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
| ), | ||
| cv.Optional(CONF_PM_2_5_NUMBER): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=0, | ||
| device_class=DEVICE_CLASS_PM25, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
| ), | ||
| cv.Optional(CONF_PM_4_0_NUMBER): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=0, | ||
| device_class=DEVICE_CLASS_PM25, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
| ), | ||
| cv.Optional(CONF_PM_10_0_NUMBER): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=0, | ||
| device_class=DEVICE_CLASS_PM25, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
|
Comment on lines
+34
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cd Integrations/ESPHome/custom_components/sen5x_number && head -40 sensor.pyRepository: ApolloAutomation/TEMP_PROJECT-1 Length of output: 206 🏁 Script executed: rg "DEVICE_CLASS|UNIT_NUMBER_PER_CUBIC_METER" Integrations/ESPHome/custom_components/sen5x_number/ -A 2 -B 2Repository: ApolloAutomation/TEMP_PROJECT-1 Length of output: 4865 🌐 Web query:
💡 Result: Home Assistant’s built‑in PM device_class (pm25 / SensorDeviceClass.PM25 or NumberDeviceClass.PM25) expects mass concentration in µg/m³ — it does not support number concentration in Citations: Remove
🔧 Suggested change cv.Optional(CONF_PM_0_5_NUMBER): sensor.sensor_schema(
unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
- device_class=DEVICE_CLASS_PM25,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PM_1_0_NUMBER): sensor.sensor_schema(
unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
- device_class=DEVICE_CLASS_PM25,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PM_2_5_NUMBER): sensor.sensor_schema(
unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
- device_class=DEVICE_CLASS_PM25,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PM_4_0_NUMBER): sensor.sensor_schema(
unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
- device_class=DEVICE_CLASS_PM25,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PM_10_0_NUMBER): sensor.sensor_schema(
unit_of_measurement=UNIT_NUMBER_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
- device_class=DEVICE_CLASS_PM25,
state_class=STATE_CLASS_MEASUREMENT,
),🤖 Prompt for AI Agents |
||
| ), | ||
| cv.Optional(CONF_TYPICAL_PARTICLE_SIZE): sensor.sensor_schema( | ||
| unit_of_measurement=UNIT_MICROMETER, | ||
| icon=ICON_CHEMICAL_WEAPON, | ||
| accuracy_decimals=2, | ||
| state_class=STATE_CLASS_MEASUREMENT, | ||
| ), | ||
| } | ||
| ) | ||
| .extend(cv.polling_component_schema("60s")) | ||
| .extend(i2c.i2c_device_schema(0x69)) | ||
| ) | ||
|
|
||
|
|
||
| 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) | ||
|
|
||
| if CONF_PM_0_5_NUMBER in config: | ||
| sens = await sensor.new_sensor(config[CONF_PM_0_5_NUMBER]) | ||
| cg.add(var.set_pm_0_5_number_sensor(sens)) | ||
|
|
||
| if CONF_PM_1_0_NUMBER in config: | ||
| sens = await sensor.new_sensor(config[CONF_PM_1_0_NUMBER]) | ||
| cg.add(var.set_pm_1_0_number_sensor(sens)) | ||
|
|
||
| if CONF_PM_2_5_NUMBER in config: | ||
| sens = await sensor.new_sensor(config[CONF_PM_2_5_NUMBER]) | ||
| cg.add(var.set_pm_2_5_number_sensor(sens)) | ||
|
|
||
| if CONF_PM_4_0_NUMBER in config: | ||
| sens = await sensor.new_sensor(config[CONF_PM_4_0_NUMBER]) | ||
| cg.add(var.set_pm_4_0_number_sensor(sens)) | ||
|
|
||
| if CONF_PM_10_0_NUMBER in config: | ||
| sens = await sensor.new_sensor(config[CONF_PM_10_0_NUMBER]) | ||
| cg.add(var.set_pm_10_0_number_sensor(sens)) | ||
|
|
||
| if CONF_TYPICAL_PARTICLE_SIZE in config: | ||
| sens = await sensor.new_sensor(config[CONF_TYPICAL_PARTICLE_SIZE]) | ||
| cg.add(var.set_typical_particle_size_sensor(sens)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return failure when no values pass CRC.
read_data_()currently returns true even if all values fail CRC, soupdate()clears the warning and silently drops data. Track whether any value was published and return false if none.🔧 Suggested change
🤖 Prompt for AI Agents