Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 379 additions & 23 deletions Integrations/ESPHome/Core.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Integrations/ESPHome/TEMP_PRO-1_ETH.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ select:
then:
- if:
condition:
lambda: 'return id(firmware_selector).state == "Ethernet";'
lambda: 'return id(firmware_selector).current_option() == "Ethernet";'
then:
- logger.log: "OTA updates set to use ethernet firmware"
- lambda: id(update_http_request).set_source_url("https://apolloautomation.github.io/TEMP_PROJECT-1/firmware-e/manifest.json");
Expand Down
2 changes: 1 addition & 1 deletion Integrations/ESPHome/TEMP_PRO-1_W.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ select:
then:
- if:
condition:
lambda: 'return id(firmware_selector).state == "Ethernet";'
lambda: 'return id(firmware_selector).current_option() == "Ethernet";'
then:
- logger.log: "OTA updates set to use ethernet firmware"
- lambda: id(update_http_request).set_source_url("https://apolloautomation.github.io/TEMP_PROJECT-1/firmware-e/manifest.json");
Expand Down
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"]
Binary file not shown.
Binary file not shown.
152 changes: 152 additions & 0 deletions Integrations/ESPHome/custom_components/sen5x_number/sen5x_number.cpp
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;
Comment on lines +89 to +148
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Return failure when no values pass CRC.
read_data_() currently returns true even if all values fail CRC, so update() clears the warning and silently drops data. Track whether any value was published and return false if none.

🔧 Suggested change
@@
-  auto parse_value = [&](uint8_t offset) -> float {
+  bool any_valid = false;
+  auto parse_value = [&](uint8_t offset) -> float {
@@
     if (!std::isnan(value)) {
       this->pm_0_5_number_->publish_state(value * 1e6f); // Convert to `#/m³`
+      any_valid = true;
     }
   }
@@
     if (!std::isnan(value)) {
       this->pm_1_0_number_->publish_state(value * 1e6f);
+      any_valid = true;
     }
   }
@@
     if (!std::isnan(value)) {
       this->pm_2_5_number_->publish_state(value * 1e6f);
+      any_valid = true;
     }
   }
@@
     if (!std::isnan(value)) {
       this->pm_4_0_number_->publish_state(value * 1e6f);
+      any_valid = true;
     }
   }
@@
     if (!std::isnan(value)) {
       this->pm_10_0_number_->publish_state(value * 1e6f);
+      any_valid = true;
     }
   }
@@
     if (!std::isnan(value)) {
       this->typical_particle_size_->publish_state(value);
+      any_valid = true;
     }
   }
 
-  return true;
+  return any_valid;
🤖 Prompt for AI Agents
In `@Integrations/ESPHome/custom_components/sen5x_number/sen5x_number.cpp` around
lines 89 - 148, read_data_() currently always returns true even when
parse_value() fails CRC for every sensor field; add a bool flag (e.g.,
any_published = false) at the start of the parsing block and set it to true
whenever you call publish_state on any of the members (pm_0_5_number_,
pm_1_0_number_, pm_2_5_number_, pm_4_0_number_, pm_10_0_number_,
typical_particle_size_); after processing all fields return any_published
instead of true so the method returns false when no values passed CRC and
nothing was published, leaving parse_value and publish_state calls unchanged.

}

} // namespace sen5x_number
} // namespace esphome
45 changes: 45 additions & 0 deletions Integrations/ESPHome/custom_components/sen5x_number/sen5x_number.h
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
109 changes: 109 additions & 0 deletions Integrations/ESPHome/custom_components/sen5x_number/sensor.py
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd Integrations/ESPHome/custom_components/sen5x_number && head -40 sensor.py

Repository: 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 2

Repository: ApolloAutomation/TEMP_PROJECT-1

Length of output: 4865


🌐 Web query:

Home Assistant sensor device_class PM25 number concentration #/m³``

💡 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 #/m³. Use a generic sensor (no device_class) or expose the reading with a unit you set (e.g. "#/m³") and keep device_class unset so HA won’t validate/overwrite the unit. (developers.home-assistant.io)

Citations:


Remove device_class=DEVICE_CLASS_PM25 from number-concentration sensors.

DEVICE_CLASS_PM25 is defined in Home Assistant for mass concentration (µg/m³) only. Using it with #/m³ (number concentration) violates Home Assistant's sensor specifications and will be rejected or misrepresented. Omit device_class for these five sensors.

🔧 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
In `@Integrations/ESPHome/custom_components/sen5x_number/sensor.py` around lines
34 - 67, Remove the inappropriate device_class from the number-concentration
sensor schemas: in sensor.py update the sensor.sensor_schema calls for
CONF_PM_0_5_NUMBER, CONF_PM_1_0_NUMBER, CONF_PM_2_5_NUMBER, CONF_PM_4_0_NUMBER
and CONF_PM_10_0_NUMBER to omit device_class=DEVICE_CLASS_PM25 (leave
unit_of_measurement, icon, accuracy_decimals and state_class intact); this
ensures the number sensors use no device_class as required by Home Assistant
specs.

),
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))