diff --git a/CODEOWNERS b/CODEOWNERS index 3047072ea2..b898788b1a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -476,6 +476,7 @@ esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 esphome/components/sfa30/* @ghsensdev +esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw esphome/components/sha256/* @esphome/core diff --git a/esphome/components/sfa40/__init__.py b/esphome/components/sfa40/__init__.py new file mode 100644 index 0000000000..79568d458a --- /dev/null +++ b/esphome/components/sfa40/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@NoQuarrel"] diff --git a/esphome/components/sfa40/sensor.py b/esphome/components/sfa40/sensor.py new file mode 100644 index 0000000000..701660eb8f --- /dev/null +++ b/esphome/components/sfa40/sensor.py @@ -0,0 +1,79 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensirion_common, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMALDEHYDE, + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ICON_FLASK_OUTLINE, + ICON_THERMOMETER, + ICON_WATER_PERCENT, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_BILLION, + UNIT_PERCENT, +) + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensirion_common"] + +CONF_WAIT_FOR_READY = "wait_for_ready" + +sfa40_ns = cg.esphome_ns.namespace("sfa40") +SFA40Component = sfa40_ns.class_( + "SFA40Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(SFA40Component), + cv.Optional(CONF_WAIT_FOR_READY, default=True): cv.boolean, + cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_FLASK_OUTLINE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_MEASUREMENT, + ), + 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, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x5D)) +) + +SENSOR_MAP = { + CONF_FORMALDEHYDE: "set_formaldehyde_sensor", + CONF_TEMPERATURE: "set_temperature_sensor", + CONF_HUMIDITY: "set_humidity_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) + cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY])) + + for key, func_name in SENSOR_MAP.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(getattr(var, func_name)(sens)) diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp new file mode 100644 index 0000000000..0d6bee3f9a --- /dev/null +++ b/esphome/components/sfa40/sfa40.cpp @@ -0,0 +1,159 @@ +#include "sfa40.h" +#include "esphome/core/log.h" +#include + +namespace esphome::sfa40 { + +static const char *const TAG = "sfa40"; + +// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf + +static const uint16_t SFA40_CMD_START_MEASUREMENT = 0x00AC; +static const uint16_t SFA40_CMD_STOP_MEASUREMENT = 0x50D2; +static const uint16_t SFA40_CMD_READ_MEASURE_PROD = 0xC0EB; +// B4 (engineering-sample) command codes. Commands from here: https://github.com/DFRobot/DFRobot_SFA40 +static const uint16_t SFA40_CMD_READ_MEASURE_B4 = 0xE06D; +static const uint16_t SFA40_CMD_READ_ID_PROD = 0x02CE; +static const uint16_t SFA40_CMD_READ_ID_B4 = 0x0559; +static const uint8_t STATUS_NOT_READY = 0x01; +static const uint8_t STATUS_OUT_OF_SPEC = 0x02; + +static uint64_t raw_to_serial(const uint16_t *raw, size_t words) { + uint64_t serial = 0; + for (size_t i = 0; i < words; i++) { + serial = (serial << 16) | raw[i]; + } + return serial; +} + +static void raw_to_marking(const uint16_t *raw, size_t words, char *out, size_t out_len) { + if (out_len < words * 2 + 1) { + return; + } + for (size_t i = 0; i < words; i++) { + out[i * 2] = static_cast(raw[i] >> 8); + out[i * 2 + 1] = static_cast(raw[i] & 0xFF); + } + out[words * 2] = '\0'; +} + +void SFA40Component::setup() { + this->write_command(SFA40_CMD_STOP_MEASUREMENT); + this->set_timeout(25, [this]() { + if (!this->detect_protocol_()) { + ESP_LOGE(TAG, "Failed to detect SFA40 protocol"); + this->error_code_ = PROTOCOL_DETECTION_FAILED; + this->mark_failed(); + return; + } + if (!this->write_command(SFA40_CMD_START_MEASUREMENT)) { + ESP_LOGE(TAG, "Failed to start measurements"); + this->error_code_ = MEASUREMENT_INIT_FAILED; + this->mark_failed(); + return; + } + this->initialized_ = true; + ESP_LOGD(TAG, "Measurement started"); + }); +} + +bool SFA40Component::detect_protocol_() { + uint16_t raw[5] = {}; + if (this->get_register(SFA40_CMD_READ_ID_PROD, raw, 3, 5)) { + this->protocol_version_ = ProtocolVersion::PRODUCTION; + this->serial_number_ = raw_to_serial(raw, 3); + ESP_LOGD(TAG, "Detected production SFA40, serial number: %012" PRIX64, this->serial_number_); + return true; + } + if (this->get_register(SFA40_CMD_READ_ID_B4, raw, 5, 5)) { + this->protocol_version_ = ProtocolVersion::PROTOTYPE; + raw_to_marking(raw, 5, this->device_marking_, sizeof(this->device_marking_)); + ESP_LOGD(TAG, "Detected engineering-sample SFA40, marking: '%s'", this->device_marking_); + return true; + } + return false; +} + +void SFA40Component::dump_config() { + ESP_LOGCONFIG(TAG, "sfa40:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + switch (this->error_code_) { + case PROTOCOL_DETECTION_FAILED: + ESP_LOGW(TAG, "Protocol detection failed!"); + break; + case MEASUREMENT_INIT_FAILED: + ESP_LOGW(TAG, "Measurement initialization failed!"); + break; + default: + ESP_LOGW(TAG, "Unknown setup error!"); + break; + } + } + LOG_UPDATE_INTERVAL(this); + switch (this->protocol_version_) { + case ProtocolVersion::PRODUCTION: + ESP_LOGCONFIG(TAG, " Protocol: production\n Serial Number: %012" PRIX64, this->serial_number_); + break; + case ProtocolVersion::PROTOTYPE: + ESP_LOGCONFIG(TAG, " Protocol: prototype (B4)\n Marking: '%s'", this->device_marking_); + break; + default: + ESP_LOGCONFIG(TAG, " Protocol: (detecting...)"); + break; + } + ESP_LOGCONFIG(TAG, " Wait for ready: %s", YESNO(this->wait_for_ready_)); + LOG_SENSOR(" ", "Formaldehyde", this->formaldehyde_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); +} + +void SFA40Component::update() { + if (!this->initialized_ || this->protocol_version_ == ProtocolVersion::UNKNOWN) { + return; + } + + const uint16_t read_cmd = (this->protocol_version_ == ProtocolVersion::PRODUCTION) ? SFA40_CMD_READ_MEASURE_PROD + : SFA40_CMD_READ_MEASURE_B4; + + if (!this->write_command(read_cmd)) { + ESP_LOGW(TAG, "Error reading measurement"); + this->status_set_warning(); + return; + } + + this->set_timeout(5, [this]() { + uint16_t raw[4]; + if (!this->read_data(raw, 4)) { + ESP_LOGW(TAG, "Error reading measurement data"); + this->status_set_warning(); + return; + } + + const uint8_t status = raw[3] >> 8; + const bool sensor_not_ready = (status & STATUS_NOT_READY) != 0; + const bool sensor_out_of_spec = (status & STATUS_OUT_OF_SPEC) != 0; + + if (this->formaldehyde_sensor_ != nullptr) { + if (sensor_out_of_spec) { + ESP_LOGW(TAG, "Skipping formaldehyde publish: sensor out of spec (status=0x%02X)", status); + } else if (this->wait_for_ready_ && sensor_not_ready) { + ESP_LOGD(TAG, "Skipping formaldehyde publish: sensor warming up"); + } else { + this->formaldehyde_sensor_->publish_state(static_cast(raw[0]) / 10.0f); + } + } + + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->publish_state(clamp(125.0f * static_cast(raw[1]) / 65535.0f - 6.0f, 0.0f, 100.0f)); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(175.0f * (static_cast(raw[2]) / 65535.0f) - 45.0f); + } + + this->status_clear_warning(); + }); +} + +} // namespace esphome::sfa40 diff --git a/esphome/components/sfa40/sfa40.h b/esphome/components/sfa40/sfa40.h new file mode 100644 index 0000000000..6f67607753 --- /dev/null +++ b/esphome/components/sfa40/sfa40.h @@ -0,0 +1,46 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/sensirion_common/i2c_sensirion.h" + +namespace esphome::sfa40 { + +// SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf + +class SFA40Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_formaldehyde_sensor(sensor::Sensor *formaldehyde) { this->formaldehyde_sensor_ = formaldehyde; } + void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } + void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } + void set_wait_for_ready(bool wait_for_ready) { this->wait_for_ready_ = wait_for_ready; } + + protected: + enum ProtocolVersion : uint8_t { + UNKNOWN = 0, + PRODUCTION = 1, + PROTOTYPE = 2, + }; + enum ErrorCode : uint8_t { + UNKNOWN_ERROR = 0, + PROTOCOL_DETECTION_FAILED, + MEASUREMENT_INIT_FAILED, + }; + bool detect_protocol_(); + ProtocolVersion protocol_version_{UNKNOWN}; + ErrorCode error_code_{UNKNOWN_ERROR}; + char device_marking_[11]{}; + bool initialized_{false}; + bool wait_for_ready_{true}; + uint64_t serial_number_{0}; + + sensor::Sensor *formaldehyde_sensor_{nullptr}; + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; +}; + +} // namespace esphome::sfa40 diff --git a/tests/components/sfa40/common.yaml b/tests/components/sfa40/common.yaml new file mode 100644 index 0000000000..d883e02cb6 --- /dev/null +++ b/tests/components/sfa40/common.yaml @@ -0,0 +1,12 @@ +sensor: + - platform: sfa40 + i2c_id: i2c_bus + wait_for_ready: false + formaldehyde: + name: SFA40 formaldehyde + temperature: + name: SFA40 temperature + humidity: + name: SFA40 humidity + address: 0x5D + update_interval: 30s diff --git a/tests/components/sfa40/test.esp32-idf.yaml b/tests/components/sfa40/test.esp32-idf.yaml new file mode 100644 index 0000000000..ff65829346 --- /dev/null +++ b/tests/components/sfa40/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/test.esp8266-ard.yaml b/tests/components/sfa40/test.esp8266-ard.yaml new file mode 100644 index 0000000000..11c0bcbfc7 --- /dev/null +++ b/tests/components/sfa40/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/test.rp2040-ard.yaml b/tests/components/sfa40/test.rp2040-ard.yaml new file mode 100644 index 0000000000..7c7f8acdf6 --- /dev/null +++ b/tests/components/sfa40/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + sfa40: !include common.yaml diff --git a/tests/components/sfa40/validate.esp32-idf.yaml b/tests/components/sfa40/validate.esp32-idf.yaml new file mode 100644 index 0000000000..6f65ac4bdb --- /dev/null +++ b/tests/components/sfa40/validate.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: sfa40 + i2c_id: i2c_bus + wait_for_ready: true + formaldehyde: + name: SFA40 formaldehyde