Initial commit: ESPHome SEN6x component
ESPHome external component for Sensirion SEN66 environmental sensor with: - PM1.0, PM2.5, PM4.0, PM10 measurements - Number concentration (particle counts) - VOC and NOx indices with algorithm tuning - CO2 measurement - Temperature and humidity - Start/stop measurement and fan cleaning actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ESPHome
|
||||
.esphome/
|
||||
secrets.yaml
|
||||
170
README.md
Normal file
170
README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# ESPHome SEN6x Component
|
||||
|
||||
ESPHome external component for Sensirion SEN66 environmental sensor with support for PM, VOC, NOx, CO2, Temperature, and Humidity.
|
||||
|
||||
## Features
|
||||
|
||||
- **Particulate Matter**: PM1.0, PM2.5, PM4.0, PM10 (differential values per size range)
|
||||
- **Number Concentration**: Particle counts for 0.5µm, 1µm, 2.5µm, 4µm, 10µm
|
||||
- **VOC Index**: With algorithm tuning support
|
||||
- **NOx Index**: With algorithm tuning support
|
||||
- **CO2**: Carbon dioxide measurement (SEN66 feature)
|
||||
- **Temperature & Humidity**: Built-in environmental monitoring
|
||||
- **Actions**: Start/stop measurement, fan cleaning
|
||||
|
||||
## Installation
|
||||
|
||||
Add to your ESPHome configuration:
|
||||
|
||||
```yaml
|
||||
external_components:
|
||||
- source:
|
||||
type: git
|
||||
url: https://github.com/YOUR_USERNAME/esphome-sen6x
|
||||
ref: main
|
||||
components: [sen6x]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```yaml
|
||||
i2c:
|
||||
sda: GPIO33
|
||||
scl: GPIO5
|
||||
|
||||
sensor:
|
||||
- platform: sen6x
|
||||
id: sen66
|
||||
address: 0x6B
|
||||
update_interval: 10s
|
||||
|
||||
pm_1_0:
|
||||
name: "PM 1.0"
|
||||
pm_2_5:
|
||||
name: "PM 2.5"
|
||||
pm_4_0:
|
||||
name: "PM 4.0"
|
||||
pm_10_0:
|
||||
name: "PM 10"
|
||||
|
||||
temperature:
|
||||
name: "Temperature"
|
||||
humidity:
|
||||
name: "Humidity"
|
||||
|
||||
voc:
|
||||
name: "VOC Index"
|
||||
algorithm_tuning:
|
||||
index_offset: 100
|
||||
learning_time_offset_hours: 12
|
||||
learning_time_gain_hours: 12
|
||||
gating_max_duration_minutes: 180
|
||||
std_initial: 50
|
||||
gain_factor: 230
|
||||
|
||||
nox:
|
||||
name: "NOx Index"
|
||||
|
||||
co2:
|
||||
name: "CO2"
|
||||
|
||||
# Optional: Number concentration sensors
|
||||
number_concentration_0_5:
|
||||
name: "Particles ≤0.5µm"
|
||||
number_concentration_1_0:
|
||||
name: "Particles ≤1µm"
|
||||
number_concentration_2_5:
|
||||
name: "Particles ≤2.5µm"
|
||||
number_concentration_4_0:
|
||||
name: "Particles ≤4µm"
|
||||
number_concentration_10_0:
|
||||
name: "Particles ≤10µm"
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
### Start/Stop Measurement
|
||||
|
||||
```yaml
|
||||
button:
|
||||
- platform: template
|
||||
name: "Stop Measurement"
|
||||
on_press:
|
||||
- sen6x.stop_measurement: sen66
|
||||
|
||||
- platform: template
|
||||
name: "Start Measurement"
|
||||
on_press:
|
||||
- sen6x.start_measurement: sen66
|
||||
```
|
||||
|
||||
### Fan Cleaning
|
||||
|
||||
```yaml
|
||||
button:
|
||||
- platform: template
|
||||
name: "Fan Cleaning"
|
||||
on_press:
|
||||
- sen6x.stop_measurement: sen66
|
||||
- delay: 1.5s
|
||||
- sen6x.start_fan_cleaning: sen66
|
||||
- delay: 30s
|
||||
- sen6x.start_measurement: sen66
|
||||
```
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
### Main Configuration
|
||||
|
||||
- **id** (*Optional*, ID): Component ID for reference in automations
|
||||
- **address** (*Optional*, int): I2C address. Default: `0x6B`
|
||||
- **update_interval** (*Optional*, Time): Sensor update interval. Default: `60s`
|
||||
- **store_baseline** (*Optional*, boolean): Store VOC baseline to flash. Default: `true`
|
||||
|
||||
### Sensor Configuration
|
||||
|
||||
All sensors are optional. Each sensor supports standard ESPHome sensor options.
|
||||
|
||||
#### PM Sensors
|
||||
- **pm_1_0**: PM1.0 concentration (µg/m³)
|
||||
- **pm_2_5**: PM2.5 concentration (µg/m³)
|
||||
- **pm_4_0**: PM4.0 concentration (µg/m³)
|
||||
- **pm_10_0**: PM10 concentration (µg/m³)
|
||||
|
||||
#### Number Concentration Sensors
|
||||
- **number_concentration_0_5**: Particles ≤0.5µm (p/cm³)
|
||||
- **number_concentration_1_0**: Particles ≤1.0µm (p/cm³)
|
||||
- **number_concentration_2_5**: Particles ≤2.5µm (p/cm³)
|
||||
- **number_concentration_4_0**: Particles ≤4.0µm (p/cm³)
|
||||
- **number_concentration_10_0**: Particles ≤10µm (p/cm³)
|
||||
|
||||
#### Gas Sensors
|
||||
- **voc**: VOC Index (1-500)
|
||||
- **nox**: NOx Index (1-500)
|
||||
- **co2**: CO2 concentration (ppm)
|
||||
|
||||
#### Environmental Sensors
|
||||
- **temperature**: Temperature (°C)
|
||||
- **humidity**: Relative humidity (%)
|
||||
|
||||
### Algorithm Tuning (VOC/NOx)
|
||||
|
||||
```yaml
|
||||
algorithm_tuning:
|
||||
index_offset: 100 # 1-250, default: 100
|
||||
learning_time_offset_hours: 12 # 1-1000, default: 12
|
||||
learning_time_gain_hours: 12 # 1-1000, default: 12
|
||||
gating_max_duration_minutes: 720 # 0-3000, default: 720
|
||||
std_initial: 50 # default: 50
|
||||
gain_factor: 230 # 1-1000, default: 230
|
||||
```
|
||||
|
||||
## Supported Sensors
|
||||
|
||||
| Sensor | PM | VOC | NOx | CO2 | T/H |
|
||||
|--------|----|----|-----|-----|-----|
|
||||
| SEN66 | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Based on work by @martgras
|
||||
1
components/sen6x/__init__.py
Normal file
1
components/sen6x/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Empty __init__.py - component configuration is in sensor.py
|
||||
34
components/sen6x/automation.h
Normal file
34
components/sen6x/automation.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "sen6x.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace sen6x {
|
||||
|
||||
template<typename... Ts> class StartFanAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit StartFanAction(SEN5XComponent *sen6x) : sen6x_(sen6x) {}
|
||||
void play(Ts... x) override { this->sen6x_->start_fan_cleaning(); }
|
||||
protected:
|
||||
SEN5XComponent *sen6x_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class StopMeasurementAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit StopMeasurementAction(SEN5XComponent *sen6x) : sen6x_(sen6x) {}
|
||||
void play(Ts... x) override { this->sen6x_->stop_measurement(); }
|
||||
protected:
|
||||
SEN5XComponent *sen6x_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class StartMeasurementAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit StartMeasurementAction(SEN5XComponent *sen6x) : sen6x_(sen6x) {}
|
||||
void play(Ts... x) override { this->sen6x_->start_measurement(); }
|
||||
protected:
|
||||
SEN5XComponent *sen6x_;
|
||||
};
|
||||
|
||||
} // namespace sen6x
|
||||
} // namespace esphome
|
||||
275
components/sen6x/sen6x.cpp
Normal file
275
components/sen6x/sen6x.cpp
Normal file
@@ -0,0 +1,275 @@
|
||||
#include "sen6x.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome {
|
||||
namespace sen6x {
|
||||
|
||||
static const char *const TAG = "sen6x";
|
||||
|
||||
// Commands
|
||||
static const uint16_t SEN5X_CMD_GET_DATA_READY_STATUS = 0x0202;
|
||||
static const uint16_t SEN5X_CMD_GET_FIRMWARE_VERSION = 0xD100;
|
||||
static const uint16_t SEN5X_CMD_GET_PRODUCT_NAME = 0xD014;
|
||||
static const uint16_t SEN5X_CMD_GET_SERIAL_NUMBER = 0xD033;
|
||||
static const uint16_t SEN5X_CMD_NOX_ALGORITHM_TUNING = 0x60E1;
|
||||
static const uint16_t SEN5X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only
|
||||
static const uint16_t SEN5X_CMD_START_CLEANING_FAN = 0x5607;
|
||||
static const uint16_t SEN5X_CMD_START_MEASUREMENTS = 0x0021;
|
||||
static const uint16_t SEN5X_CMD_START_MEASUREMENTS_RHT_ONLY = 0x0037;
|
||||
static const uint16_t SEN5X_CMD_STOP_MEASUREMENTS = 0x0104;
|
||||
static const uint16_t SEN5X_CMD_TEMPERATURE_COMPENSATION = 0x60B2;
|
||||
static const uint16_t SEN5X_CMD_VOC_ALGORITHM_STATE = 0x6181;
|
||||
static const uint16_t SEN5X_CMD_VOC_ALGORITHM_TUNING = 0x60D0;
|
||||
|
||||
static const uint16_t SEN6X_CMD_RESET = 0xD304;
|
||||
static const uint16_t SEN6X_CMD_READ_NUMBER_CONCENTRATION = 0x0316;
|
||||
|
||||
void SEN5XComponent::setup() {
|
||||
ESP_LOGCONFIG(TAG, "Setting up sen6x...");
|
||||
|
||||
this->set_timeout(2000, [this]() {
|
||||
|
||||
if (!this->write_command(SEN5X_CMD_GET_DATA_READY_STATUS)) {
|
||||
ESP_LOGE(TAG, "Failed to write data ready status command");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t raw_read_status;
|
||||
if (!this->read_data(raw_read_status)) {
|
||||
ESP_LOGE(TAG, "Failed to read data ready status");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t stop_measurement_delay = 0;
|
||||
|
||||
if (raw_read_status) {
|
||||
ESP_LOGD(TAG, "Sensor has data available, stopping periodic measurement / reset");
|
||||
if (!this->write_command(SEN6X_CMD_RESET)) {
|
||||
ESP_LOGE(TAG, "Failed to stop measurements / reset");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Datasheet: sensor needs 200 ms after reset before accepting new commands
|
||||
delay(200);
|
||||
|
||||
stop_measurement_delay = 1200;
|
||||
}
|
||||
|
||||
this->set_timeout(stop_measurement_delay, [this]() {
|
||||
uint16_t raw_serial_number[3];
|
||||
if (!this->get_register(SEN5X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 3, 20)) {
|
||||
ESP_LOGE(TAG, "Failed to read serial number");
|
||||
this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->serial_number_[0] = static_cast<bool>(uint16_t(raw_serial_number[0]) & 0xFF);
|
||||
this->serial_number_[1] = static_cast<uint16_t>(raw_serial_number[0] & 0xFF);
|
||||
this->serial_number_[2] = static_cast<uint16_t>(raw_serial_number[1] >> 8);
|
||||
|
||||
uint16_t raw_product_name[16];
|
||||
if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
|
||||
ESP_LOGE(TAG, "Failed to read product name");
|
||||
this->error_code_ = PRODUCT_NAME_FAILED;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t *current_int = raw_product_name;
|
||||
char current_char;
|
||||
uint8_t max = 16;
|
||||
do {
|
||||
current_char = *current_int >> 8;
|
||||
if (current_char) {
|
||||
product_name_.push_back(current_char);
|
||||
current_char = *current_int & 0xFF;
|
||||
if (current_char)
|
||||
product_name_.push_back(current_char);
|
||||
}
|
||||
current_int++;
|
||||
} while (current_char && --max);
|
||||
|
||||
ESP_LOGD(TAG, "Productname %s", product_name_.c_str());
|
||||
|
||||
// Start measurement now
|
||||
if (!this->write_command(SEN5X_CMD_START_MEASUREMENTS)) {
|
||||
ESP_LOGE(TAG, "Error starting continuous measurements.");
|
||||
this->error_code_ = MEASUREMENT_INIT_FAILED;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
this->is_measuring_ = true;
|
||||
initialized_ = true;
|
||||
ESP_LOGD(TAG, "Sensor initialized and measuring");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void SEN5XComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "sen6x:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// MEASUREMENT READING
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void SEN5XComponent::update() {
|
||||
if (!initialized_) return;
|
||||
|
||||
// If measurement stopped: publish NAN for all
|
||||
if (!this->is_measuring_) {
|
||||
auto set_nan = [&](sensor::Sensor *s) {
|
||||
if (s != nullptr) s->publish_state(NAN);
|
||||
};
|
||||
|
||||
set_nan(this->pm_1_0_sensor_);
|
||||
set_nan(this->pm_2_5_sensor_);
|
||||
set_nan(this->pm_4_0_sensor_);
|
||||
set_nan(this->pm_10_0_sensor_);
|
||||
set_nan(this->pm_0_10_sensor_);
|
||||
set_nan(this->temperature_sensor_);
|
||||
set_nan(this->humidity_sensor_);
|
||||
set_nan(this->voc_sensor_);
|
||||
set_nan(this->nox_sensor_);
|
||||
set_nan(this->co2_sensor_);
|
||||
set_nan(this->nc_0_5_sensor_);
|
||||
set_nan(this->nc_1_0_sensor_);
|
||||
set_nan(this->nc_2_5_sensor_);
|
||||
set_nan(this->nc_4_0_sensor_);
|
||||
set_nan(this->nc_10_0_sensor_);
|
||||
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
|
||||
// Read standard measurements
|
||||
if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGD(TAG, "write error read measurement (%d)", this->last_error_);
|
||||
return;
|
||||
}
|
||||
|
||||
this->set_timeout(20, [this]() {
|
||||
uint16_t measurements[9];
|
||||
if (!this->read_data(measurements, 9)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGD(TAG, "read data error (%d)", this->last_error_);
|
||||
return;
|
||||
}
|
||||
|
||||
float pm_1_0 = (measurements[0] == 0xFFFF) ? NAN : measurements[0] / 10.0f;
|
||||
float pm_2_5 = (measurements[1] == 0xFFFF || measurements[0] == 0xFFFF) ? NAN : (measurements[1] - measurements[0]) / 10.0f;
|
||||
float pm_4_0 = (measurements[2] == 0xFFFF || measurements[1] == 0xFFFF) ? NAN : (measurements[2] - measurements[1]) / 10.0f;
|
||||
float pm_10_0 = (measurements[3] == 0xFFFF || measurements[2] == 0xFFFF) ? NAN : (measurements[3] - measurements[2]) / 10.0f;
|
||||
float pm_0_10 = (measurements[3] == 0xFFFF) ? NAN : measurements[3] / 10.0f;
|
||||
float humidity = (measurements[4] == 0xFFFF) ? NAN : measurements[4] / 100.0f;
|
||||
float temperature = (measurements[5] == 0xFFFF) ? NAN : (int16_t) measurements[5] / 200.0f;
|
||||
float voc = (measurements[6] == 0x7FFF) ? NAN : measurements[6] / 10.0f;
|
||||
float nox = (measurements[7] == 0x7FFF) ? NAN : measurements[7] / 10.0f;
|
||||
float co2 = (measurements[8] == 0xFFFF) ? NAN : measurements[8];
|
||||
|
||||
if (this->pm_1_0_sensor_) this->pm_1_0_sensor_->publish_state(pm_1_0);
|
||||
if (this->pm_2_5_sensor_) this->pm_2_5_sensor_->publish_state(pm_2_5);
|
||||
if (this->pm_4_0_sensor_) this->pm_4_0_sensor_->publish_state(pm_4_0);
|
||||
if (this->pm_10_0_sensor_) this->pm_10_0_sensor_->publish_state(pm_10_0);
|
||||
if (this->pm_0_10_sensor_) this->pm_0_10_sensor_->publish_state(pm_0_10);
|
||||
if (this->temperature_sensor_) this->temperature_sensor_->publish_state(temperature);
|
||||
if (this->humidity_sensor_) this->humidity_sensor_->publish_state(humidity);
|
||||
if (this->voc_sensor_) this->voc_sensor_->publish_state(voc);
|
||||
if (this->nox_sensor_) this->nox_sensor_->publish_state(nox);
|
||||
if (this->co2_sensor_) this->co2_sensor_->publish_state(co2);
|
||||
|
||||
this->status_clear_warning();
|
||||
|
||||
// Number Concentration with 50 ms delay
|
||||
this->set_timeout(50, [this]() {
|
||||
uint16_t nc05_u, nc10_u, nc25_u, nc40_u, nc100_u;
|
||||
if (!this->read_number_concentration(&nc05_u, &nc10_u, &nc25_u, &nc40_u, &nc100_u))
|
||||
return;
|
||||
|
||||
auto conv = [](uint16_t v) -> float {
|
||||
return (v == 0xFFFF) ? NAN : (float) v;
|
||||
};
|
||||
|
||||
if (this->nc_0_5_sensor_) this->nc_0_5_sensor_->publish_state(conv(nc05_u));
|
||||
if (this->nc_1_0_sensor_) this->nc_1_0_sensor_->publish_state(conv(nc10_u));
|
||||
if (this->nc_2_5_sensor_) this->nc_2_5_sensor_->publish_state(conv(nc25_u));
|
||||
if (this->nc_4_0_sensor_) this->nc_4_0_sensor_->publish_state(conv(nc40_u));
|
||||
if (this->nc_10_0_sensor_) this->nc_10_0_sensor_->publish_state(conv(nc100_u));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FUNCTIONS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
bool SEN5XComponent::read_number_concentration(uint16_t *nc05, uint16_t *nc10,
|
||||
uint16_t *nc25, uint16_t *nc40,
|
||||
uint16_t *nc100) {
|
||||
uint16_t raw[5];
|
||||
|
||||
if (!this->write_command(SEN6X_CMD_READ_NUMBER_CONCENTRATION)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGE(TAG, "Error writing Number Concentration command (0x0316), err=%d", this->last_error_);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->read_data(raw, 5)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGE(TAG, "Error reading Number Concentration values (0x0316), err=%d", this->last_error_);
|
||||
return false;
|
||||
}
|
||||
|
||||
*nc05 = raw[0];
|
||||
*nc10 = raw[1];
|
||||
*nc25 = raw[2];
|
||||
*nc40 = raw[3];
|
||||
*nc100 = raw[4];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SEN5XComponent::start_measurement() {
|
||||
if (!write_command(SEN5X_CMD_START_MEASUREMENTS)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGE(TAG, "write error start measurement (%d)", this->last_error_);
|
||||
return false;
|
||||
}
|
||||
this->is_measuring_ = true;
|
||||
ESP_LOGD(TAG, "Measurement started");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SEN5XComponent::stop_measurement() {
|
||||
if (!write_command(SEN5X_CMD_STOP_MEASUREMENTS)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGE(TAG, "write error stop measurement (%d)", this->last_error_);
|
||||
return false;
|
||||
}
|
||||
this->is_measuring_ = false;
|
||||
ESP_LOGD(TAG, "Measurement stopped");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SEN5XComponent::start_fan_cleaning() {
|
||||
if (!write_command(SEN5X_CMD_START_CLEANING_FAN)) {
|
||||
this->status_set_warning();
|
||||
ESP_LOGE(TAG, "write error start fan (%d)", this->last_error_);
|
||||
return false;
|
||||
}
|
||||
ESP_LOGD(TAG, "Fan auto clean started");
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace sen6x
|
||||
} // namespace esphome
|
||||
163
components/sen6x/sen6x.h
Normal file
163
components/sen6x/sen6x.h
Normal file
@@ -0,0 +1,163 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/sensirion_common/i2c_sensirion.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace sen6x {
|
||||
|
||||
enum ERRORCODE {
|
||||
COMMUNICATION_FAILED,
|
||||
SERIAL_NUMBER_IDENTIFICATION_FAILED,
|
||||
MEASUREMENT_INIT_FAILED,
|
||||
PRODUCT_NAME_FAILED,
|
||||
FIRMWARE_FAILED,
|
||||
UNKNOWN
|
||||
};
|
||||
|
||||
// Shortest time interval of 3H for storing baseline values.
|
||||
// Prevents wear of the flash because of too many write operations
|
||||
const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800;
|
||||
// Store anyway if the baseline difference exceeds the max storage diff value
|
||||
const uint32_t MAXIMUM_STORAGE_DIFF = 50;
|
||||
|
||||
struct Sen5xBaselines {
|
||||
int32_t state0;
|
||||
int32_t state1;
|
||||
} PACKED; // NOLINT
|
||||
|
||||
|
||||
struct GasTuning {
|
||||
uint16_t index_offset;
|
||||
uint16_t learning_time_offset_hours;
|
||||
uint16_t learning_time_gain_hours;
|
||||
uint16_t gating_max_duration_minutes;
|
||||
uint16_t std_initial;
|
||||
uint16_t gain_factor;
|
||||
};
|
||||
|
||||
struct TemperatureCompensation {
|
||||
int16_t offset;
|
||||
int16_t normalized_offset_slope;
|
||||
uint16_t time_constant;
|
||||
};
|
||||
|
||||
class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice {
|
||||
public:
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
|
||||
enum Sen5xType { SEN50, SEN54, SEN55, UNKNOWN };
|
||||
|
||||
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_pm_0_10_sensor(sensor::Sensor *pm_0_10) { pm_0_10_sensor_ = pm_0_10; }
|
||||
|
||||
void set_nc_0_5_sensor(sensor::Sensor *s) { nc_0_5_sensor_ = s; }
|
||||
void set_nc_1_0_sensor(sensor::Sensor *s) { nc_1_0_sensor_ = s; }
|
||||
void set_nc_2_5_sensor(sensor::Sensor *s) { nc_2_5_sensor_ = s; }
|
||||
void set_nc_4_0_sensor(sensor::Sensor *s) { nc_4_0_sensor_ = s; }
|
||||
void set_nc_10_0_sensor(sensor::Sensor *s) { nc_10_0_sensor_ = s; }
|
||||
|
||||
void set_voc_sensor(sensor::Sensor *voc_sensor) { voc_sensor_ = voc_sensor; }
|
||||
void set_nox_sensor(sensor::Sensor *nox_sensor) { nox_sensor_ = nox_sensor; }
|
||||
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; }
|
||||
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; }
|
||||
void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; }
|
||||
void set_store_baseline(bool store_baseline) { store_baseline_ = store_baseline; }
|
||||
void set_voc_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours,
|
||||
uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes,
|
||||
uint16_t std_initial, uint16_t gain_factor) {
|
||||
GasTuning tuning_params;
|
||||
tuning_params.index_offset = index_offset;
|
||||
tuning_params.learning_time_offset_hours = learning_time_offset_hours;
|
||||
tuning_params.learning_time_gain_hours = learning_time_gain_hours;
|
||||
tuning_params.gating_max_duration_minutes = gating_max_duration_minutes;
|
||||
tuning_params.std_initial = std_initial;
|
||||
tuning_params.gain_factor = gain_factor;
|
||||
voc_tuning_params_ = tuning_params;
|
||||
}
|
||||
void set_nox_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours,
|
||||
uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes,
|
||||
uint16_t gain_factor) {
|
||||
GasTuning tuning_params;
|
||||
tuning_params.index_offset = index_offset;
|
||||
tuning_params.learning_time_offset_hours = learning_time_offset_hours;
|
||||
tuning_params.learning_time_gain_hours = learning_time_gain_hours;
|
||||
tuning_params.gating_max_duration_minutes = gating_max_duration_minutes;
|
||||
tuning_params.std_initial = 50;
|
||||
tuning_params.gain_factor = gain_factor;
|
||||
nox_tuning_params_ = tuning_params;
|
||||
}
|
||||
void set_temperature_compensation(float offset, float normalized_offset_slope, uint16_t time_constant) {
|
||||
TemperatureCompensation temp_comp;
|
||||
temp_comp.offset = offset * 200;
|
||||
temp_comp.normalized_offset_slope = normalized_offset_slope * 10000;
|
||||
temp_comp.time_constant = time_constant;
|
||||
temperature_compensation_ = temp_comp;
|
||||
}
|
||||
bool start_measurement();
|
||||
bool stop_measurement();
|
||||
bool start_fan_cleaning();
|
||||
|
||||
bool read_number_concentration(uint16_t *nc05, uint16_t *nc10,
|
||||
uint16_t *nc25, uint16_t *nc40,
|
||||
uint16_t *nc100);
|
||||
|
||||
std::string get_product_name() const { return product_name_; }
|
||||
uint16_t get_firmware_version() const { return firmware_version_; }
|
||||
std::string get_serial_string() const {
|
||||
char buf[32];
|
||||
sprintf(buf, "%02d.%02d.%02d", serial_number_[0], serial_number_[1], serial_number_[2]);
|
||||
return std::string(buf);
|
||||
}
|
||||
bool is_measuring() const { return this->is_measuring_; }
|
||||
|
||||
protected:
|
||||
bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning);
|
||||
bool write_temperature_compensation_(const TemperatureCompensation &compensation);
|
||||
ERRORCODE error_code_;
|
||||
bool initialized_{false};
|
||||
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 *pm_0_10_sensor_{nullptr};
|
||||
// SEN54 and SEN55 only
|
||||
sensor::Sensor *temperature_sensor_{nullptr};
|
||||
sensor::Sensor *humidity_sensor_{nullptr};
|
||||
sensor::Sensor *voc_sensor_{nullptr};
|
||||
// SEN55 only
|
||||
sensor::Sensor *nox_sensor_{nullptr};
|
||||
sensor::Sensor *co2_sensor_{nullptr};
|
||||
|
||||
sensor::Sensor *nc_0_5_sensor_{nullptr};
|
||||
sensor::Sensor *nc_1_0_sensor_{nullptr};
|
||||
sensor::Sensor *nc_2_5_sensor_{nullptr};
|
||||
sensor::Sensor *nc_4_0_sensor_{nullptr};
|
||||
sensor::Sensor *nc_10_0_sensor_{nullptr};
|
||||
|
||||
std::string product_name_;
|
||||
uint8_t serial_number_[4];
|
||||
uint16_t firmware_version_;
|
||||
Sen5xBaselines voc_baselines_storage_;
|
||||
bool store_baseline_;
|
||||
uint32_t seconds_since_last_store_;
|
||||
ESPPreferenceObject pref_;
|
||||
optional<GasTuning> voc_tuning_params_;
|
||||
optional<GasTuning> nox_tuning_params_;
|
||||
optional<TemperatureCompensation> temperature_compensation_;
|
||||
|
||||
bool is_measuring_ = true; // Sensor runs on boot -> Default true
|
||||
|
||||
};
|
||||
|
||||
} // namespace sen6x
|
||||
} // namespace esphome
|
||||
227
components/sen6x/sensor.py
Normal file
227
components/sen6x/sensor.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensirion_common, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_HUMIDITY,
|
||||
CONF_ID,
|
||||
CONF_OFFSET,
|
||||
CONF_PM_1_0,
|
||||
CONF_PM_2_5,
|
||||
CONF_PM_4_0,
|
||||
CONF_PM_10_0,
|
||||
CONF_CO2,
|
||||
CONF_STORE_BASELINE,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TEMPERATURE_COMPENSATION,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
ICON_CHEMICAL_WEAPON,
|
||||
ICON_RADIATOR,
|
||||
ICON_THERMOMETER,
|
||||
ICON_WATER_PERCENT,
|
||||
ICON_MOLECULE_CO2,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
|
||||
# ---------- NEW Constant Names ----------
|
||||
CONF_NC_0_5 = "number_concentration_0_5"
|
||||
CONF_NC_1_0 = "number_concentration_1_0"
|
||||
CONF_NC_2_5 = "number_concentration_2_5"
|
||||
CONF_NC_4_0 = "number_concentration_4_0"
|
||||
CONF_NC_10_0 = "number_concentration_10_0"
|
||||
|
||||
CONF_ALGORITHM_TUNING = "algorithm_tuning"
|
||||
|
||||
CODEOWNERS = ["@martgras"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["sensirion_common"]
|
||||
|
||||
sen6x_ns = cg.esphome_ns.namespace("sen6x")
|
||||
SEN5XComponent = sen6x_ns.class_("SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice)
|
||||
|
||||
# ------------ Tuning Schema -------------
|
||||
GAS_SENSOR = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(
|
||||
{
|
||||
cv.Optional("index_offset", default=100): cv.int_range(1, 250),
|
||||
cv.Optional("learning_time_offset_hours", default=12): cv.int_range(1, 1000),
|
||||
cv.Optional("learning_time_gain_hours", default=12): cv.int_range(1, 1000),
|
||||
cv.Optional("gating_max_duration_minutes", default=720): cv.int_range(0, 3000),
|
||||
cv.Optional("std_initial", default=50): cv.int_,
|
||||
cv.Optional("gain_factor", default=230): cv.int_range(1, 1000),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# ------------ Fix percentage trap -------------
|
||||
def float_previously_pct(value):
|
||||
if isinstance(value, str) and "%" in value:
|
||||
raise cv.Invalid(
|
||||
f"The value '{value}' is a percentage. Suggested value: {float(value.strip('%')) / 100}"
|
||||
)
|
||||
return value
|
||||
|
||||
# ------------ CONFIG SCHEMA -------------
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SEN5XComponent),
|
||||
|
||||
# ------- PM Sensors -------
|
||||
cv.Optional(CONF_PM_1_0): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_PM1,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_PM_2_5): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_PM25,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_PM_4_0): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=2,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_PM_10_0): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_PM10,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
|
||||
# ------- VOC, NOx, CO2 -------
|
||||
cv.Optional("voc"): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(GAS_SENSOR),
|
||||
cv.Optional("nox"): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(GAS_SENSOR),
|
||||
cv.Optional(CONF_CO2): 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.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
|
||||
cv.Optional("voc_baseline"): cv.hex_uint16_t,
|
||||
|
||||
# ------- Temperature, Humidity -------
|
||||
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
icon=ICON_THERMOMETER,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
icon=ICON_WATER_PERCENT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
|
||||
# ------- Compensation -------
|
||||
cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_OFFSET, default=0): cv.float_,
|
||||
cv.Optional("normalized_offset_slope", default=0): cv.All(float_previously_pct, cv.float_),
|
||||
cv.Optional("time_constant", default=0): cv.int_,
|
||||
}
|
||||
),
|
||||
|
||||
# ------- NEW → Number concentration sensors -------
|
||||
cv.Optional(CONF_NC_0_5): sensor.sensor_schema(unit_of_measurement="p/cm³", icon="mdi:counter", accuracy_decimals=0),
|
||||
cv.Optional(CONF_NC_1_0): sensor.sensor_schema(unit_of_measurement="p/cm³", icon="mdi:counter", accuracy_decimals=0),
|
||||
cv.Optional(CONF_NC_2_5): sensor.sensor_schema(unit_of_measurement="p/cm³", icon="mdi:counter", accuracy_decimals=0),
|
||||
cv.Optional(CONF_NC_4_0): sensor.sensor_schema(unit_of_measurement="p/cm³", icon="mdi:counter", accuracy_decimals=0),
|
||||
cv.Optional(CONF_NC_10_0): sensor.sensor_schema(unit_of_measurement="p/cm³", icon="mdi:counter", accuracy_decimals=0),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(i2c.i2c_device_schema(0x6B))
|
||||
)
|
||||
|
||||
# ------------ Map PM, VOC, NOx etc. -------------
|
||||
SENSOR_MAP = {
|
||||
CONF_PM_1_0: "set_pm_1_0_sensor",
|
||||
CONF_PM_2_5: "set_pm_2_5_sensor",
|
||||
CONF_PM_4_0: "set_pm_4_0_sensor",
|
||||
CONF_PM_10_0: "set_pm_10_0_sensor",
|
||||
"voc": "set_voc_sensor",
|
||||
"nox": "set_nox_sensor",
|
||||
CONF_TEMPERATURE: "set_temperature_sensor",
|
||||
CONF_HUMIDITY: "set_humidity_sensor",
|
||||
CONF_CO2: "set_co2_sensor",
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
# Map standard sensors
|
||||
for key, func in SENSOR_MAP.items():
|
||||
if key in config:
|
||||
cg.add(getattr(var, func)(await sensor.new_sensor(config[key])))
|
||||
|
||||
# Map number concentration sensors
|
||||
for (cfg, func) in [
|
||||
(CONF_NC_0_5, "set_nc_0_5_sensor"),
|
||||
(CONF_NC_1_0, "set_nc_1_0_sensor"),
|
||||
(CONF_NC_2_5, "set_nc_2_5_sensor"),
|
||||
(CONF_NC_4_0, "set_nc_4_0_sensor"),
|
||||
(CONF_NC_10_0, "set_nc_10_0_sensor"),
|
||||
]:
|
||||
if cfg in config:
|
||||
cg.add(getattr(var, func)(await sensor.new_sensor(config[cfg])))
|
||||
|
||||
# ---- Actions ----
|
||||
StartMeasurementAction = sen6x_ns.class_("StartMeasurementAction", automation.Action)
|
||||
StopMeasurementAction = sen6x_ns.class_("StopMeasurementAction", automation.Action)
|
||||
StartFanAction = sen6x_ns.class_("StartFanAction", automation.Action)
|
||||
|
||||
SEN5X_ACTION_SCHEMA = maybe_simple_id({cv.Required(CONF_ID): cv.use_id(SEN5XComponent)})
|
||||
|
||||
@automation.register_action("sen6x.start_measurement", StartMeasurementAction, SEN5X_ACTION_SCHEMA)
|
||||
async def sen6x_start_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
@automation.register_action("sen6x.stop_measurement", StopMeasurementAction, SEN5X_ACTION_SCHEMA)
|
||||
async def sen6x_stop_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
@automation.register_action("sen6x.start_fan_cleaning", StartFanAction, SEN5X_ACTION_SCHEMA)
|
||||
async def sen6x_fan_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, parent)
|
||||
Reference in New Issue
Block a user