mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
co-authored by
J. Nick Koston
Jesse Hills
parent
99a4ae1f51
commit
318e4b79c1
@@ -39,6 +39,7 @@ esphome/components/am43/sensor/* @buxtronix
|
||||
esphome/components/analog_threshold/* @ianchi
|
||||
esphome/components/animation/* @syndlex
|
||||
esphome/components/anova/* @buxtronix
|
||||
esphome/components/apc1/* @ademuri
|
||||
esphome/components/apds9306/* @aodrenah
|
||||
esphome/components/apds9930/* @iyesin
|
||||
esphome/components/api/* @esphome/core
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Support for ScioSense APC1 air quality sensor."""
|
||||
|
||||
DOMAIN = "apc1"
|
||||
@@ -0,0 +1,352 @@
|
||||
#include "apc1.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::apc1 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "apc1");
|
||||
|
||||
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58;
|
||||
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61;
|
||||
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4;
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10;
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19;
|
||||
|
||||
// Give a freshly powered sensor time to boot before the first retry
|
||||
static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000;
|
||||
// Re-send the init commands at most this often while no frames arrive
|
||||
static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000;
|
||||
// Active mode streams a frame every second; this long without one means the sensor stopped
|
||||
static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000;
|
||||
// Drop a partial frame when the bytes stop mid-way
|
||||
static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200;
|
||||
|
||||
// Frames and commands end with the 16-bit sum of every byte before it
|
||||
static uint16_t apc1_checksum(const uint8_t *data, size_t len) {
|
||||
uint16_t sum = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
sum += data[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
void APC1Component::setup() {
|
||||
if (this->set_pin_ != nullptr) {
|
||||
this->set_pin_->setup();
|
||||
this->set_pin_->digital_write(true);
|
||||
}
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
// Attempt initial communication immediately for warm restarts
|
||||
this->initialize_device_();
|
||||
|
||||
// Schedule delayed initialization to catch sensors during cold power-on bootup
|
||||
this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); });
|
||||
}
|
||||
|
||||
void APC1Component::initialize_device_() {
|
||||
this->last_init_attempt_ = App.get_loop_component_start_time();
|
||||
if (this->active_mode_ && !this->idle_) {
|
||||
this->set_active_mode();
|
||||
}
|
||||
this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000);
|
||||
}
|
||||
|
||||
void APC1Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "APC1:");
|
||||
LOG_PIN(" Set Pin: ", this->set_pin_);
|
||||
LOG_PIN(" Reset Pin: ", this->reset_pin_);
|
||||
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
|
||||
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
|
||||
LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_);
|
||||
LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_);
|
||||
LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_);
|
||||
LOG_SENSOR(" ", "eCO2", this->eco2_sensor_);
|
||||
LOG_SENSOR(" ", "AQI", this->aqi_sensor_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
|
||||
LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_);
|
||||
LOG_SENSOR(" ", "RS0", this->rs0_sensor_);
|
||||
LOG_SENSOR(" ", "RS2", this->rs2_sensor_);
|
||||
LOG_SENSOR(" ", "RS3", this->rs3_sensor_);
|
||||
LOG_SENSOR(" ", "Error Code", this->error_code_sensor_);
|
||||
}
|
||||
|
||||
void APC1Component::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
// If in active mode and not idle, retry periodically if no valid data received
|
||||
if (this->active_mode_ && !this->idle_) {
|
||||
if (this->last_valid_frame_ == 0) {
|
||||
if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
|
||||
ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands...");
|
||||
this->initialize_device_();
|
||||
}
|
||||
} else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS &&
|
||||
now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
|
||||
ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_);
|
||||
this->initialize_device_();
|
||||
}
|
||||
}
|
||||
|
||||
if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) {
|
||||
ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer");
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
|
||||
while (this->available()) {
|
||||
this->last_transmission_ = now;
|
||||
uint8_t byte;
|
||||
this->read_byte(&byte);
|
||||
|
||||
if (this->rx_index_ == 0) {
|
||||
if (byte == APC1_HEADER_1) {
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this->rx_index_ == 1) {
|
||||
if (byte == APC1_HEADER_2) {
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
} else if (byte != APC1_HEADER_1) {
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a
|
||||
// corrupt length from wrapping the total and walking past the buffer
|
||||
const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]);
|
||||
if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) {
|
||||
ESP_LOGW(TAG, "Invalid frame length: %u", payload_len);
|
||||
this->rx_index_ = 0;
|
||||
continue;
|
||||
}
|
||||
const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE;
|
||||
if (this->rx_index_ == total_len) {
|
||||
this->parse_frame_(total_len);
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void APC1Component::parse_frame_(uint16_t total_len) {
|
||||
uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]);
|
||||
uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2);
|
||||
if (expected_checksum != calculated_checksum) {
|
||||
ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum,
|
||||
expected_checksum);
|
||||
return;
|
||||
}
|
||||
|
||||
if (total_len == APC1_FRAME_SIZE_MEASUREMENT) {
|
||||
this->parse_measurement_frame_();
|
||||
} else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) {
|
||||
this->parse_device_info_frame_();
|
||||
} else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) {
|
||||
ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Received frame of unknown length %u", total_len);
|
||||
}
|
||||
}
|
||||
|
||||
// One message per error bit, bit 0 first
|
||||
PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault",
|
||||
"Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault");
|
||||
|
||||
void APC1Component::parse_measurement_frame_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_valid_frame_ = now;
|
||||
|
||||
uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE];
|
||||
if (error_code != this->last_error_code_) {
|
||||
if (error_code != 0) {
|
||||
for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) {
|
||||
if (error_code & (1 << bit)) {
|
||||
ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX)));
|
||||
}
|
||||
}
|
||||
this->status_set_warning(LOG_STR("Sensor reported hardware error"));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "APC1: Hardware error cleared");
|
||||
this->status_clear_warning();
|
||||
}
|
||||
this->last_error_code_ = error_code;
|
||||
if (this->error_code_sensor_ != nullptr) {
|
||||
this->error_code_sensor_->publish_state(error_code);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI];
|
||||
if (aqi == 0) {
|
||||
if (!this->gas_warming_up_) {
|
||||
this->gas_warming_up_ = true;
|
||||
ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)");
|
||||
}
|
||||
} else if (this->gas_warming_up_) {
|
||||
this->gas_warming_up_ = false;
|
||||
ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi);
|
||||
}
|
||||
|
||||
// Measurement payload data (bytes 4-57): big-endian sensor readings.
|
||||
// The values below feed the debug summary, so they are always decoded; every
|
||||
// other reading is only decoded when its sensor is configured.
|
||||
const auto u16 = [this](uint8_t offset) {
|
||||
return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]);
|
||||
};
|
||||
const auto u32 = [this](uint8_t offset) {
|
||||
return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2],
|
||||
this->rx_buffer_[offset + 3]);
|
||||
};
|
||||
uint16_t pm_1_0 = u16(10);
|
||||
uint16_t pm_2_5 = u16(12);
|
||||
uint16_t pm_10_0 = u16(14);
|
||||
uint16_t tvoc = u16(28);
|
||||
uint16_t eco2 = u16(30);
|
||||
float temp_comp = static_cast<int16_t>(u16(34)) / 10.0f;
|
||||
float hum_comp = u16(36) / 10.0f;
|
||||
|
||||
ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0,
|
||||
pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp);
|
||||
|
||||
if (this->pm_1_0_std_sensor_ != nullptr)
|
||||
this->pm_1_0_std_sensor_->publish_state(u16(4));
|
||||
if (this->pm_2_5_std_sensor_ != nullptr)
|
||||
this->pm_2_5_std_sensor_->publish_state(u16(6));
|
||||
if (this->pm_10_0_std_sensor_ != nullptr)
|
||||
this->pm_10_0_std_sensor_->publish_state(u16(8));
|
||||
|
||||
if (this->pm_1_0_sensor_ != nullptr)
|
||||
this->pm_1_0_sensor_->publish_state(pm_1_0);
|
||||
if (this->pm_2_5_sensor_ != nullptr)
|
||||
this->pm_2_5_sensor_->publish_state(pm_2_5);
|
||||
if (this->pm_10_0_sensor_ != nullptr)
|
||||
this->pm_10_0_sensor_->publish_state(pm_10_0);
|
||||
|
||||
if (this->pm_0_3um_sensor_ != nullptr)
|
||||
this->pm_0_3um_sensor_->publish_state(u16(16));
|
||||
if (this->pm_0_5um_sensor_ != nullptr)
|
||||
this->pm_0_5um_sensor_->publish_state(u16(18));
|
||||
if (this->pm_1_0um_sensor_ != nullptr)
|
||||
this->pm_1_0um_sensor_->publish_state(u16(20));
|
||||
if (this->pm_2_5um_sensor_ != nullptr)
|
||||
this->pm_2_5um_sensor_->publish_state(u16(22));
|
||||
if (this->pm_5_0um_sensor_ != nullptr)
|
||||
this->pm_5_0um_sensor_->publish_state(u16(24));
|
||||
if (this->pm_10_0um_sensor_ != nullptr)
|
||||
this->pm_10_0um_sensor_->publish_state(u16(26));
|
||||
|
||||
if (this->tvoc_sensor_ != nullptr)
|
||||
this->tvoc_sensor_->publish_state(tvoc);
|
||||
if (this->eco2_sensor_ != nullptr)
|
||||
this->eco2_sensor_->publish_state(eco2);
|
||||
if (this->aqi_sensor_ != nullptr)
|
||||
this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN);
|
||||
|
||||
if (this->temperature_sensor_ != nullptr)
|
||||
this->temperature_sensor_->publish_state(temp_comp);
|
||||
if (this->humidity_sensor_ != nullptr)
|
||||
this->humidity_sensor_->publish_state(hum_comp);
|
||||
|
||||
if (this->raw_temperature_sensor_ != nullptr) {
|
||||
this->raw_temperature_sensor_->publish_state(static_cast<int16_t>(u16(38)) / 10.0f);
|
||||
}
|
||||
if (this->raw_humidity_sensor_ != nullptr)
|
||||
this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f);
|
||||
|
||||
if (this->rs0_sensor_ != nullptr) {
|
||||
this->rs0_sensor_->publish_state(u32(42));
|
||||
}
|
||||
if (this->rs2_sensor_ != nullptr) {
|
||||
this->rs2_sensor_->publish_state(u32(50));
|
||||
}
|
||||
if (this->rs3_sensor_ != nullptr) {
|
||||
this->rs3_sensor_->publish_state(u32(54));
|
||||
}
|
||||
}
|
||||
|
||||
void APC1Component::parse_device_info_frame_() {
|
||||
const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL;
|
||||
uint16_t fw_version =
|
||||
encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]);
|
||||
ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)",
|
||||
reinterpret_cast<const char *>(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE),
|
||||
encode_uint32(serial[0], serial[1], serial[2], serial[3]),
|
||||
encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version);
|
||||
}
|
||||
|
||||
void APC1Component::arm_watchdog_() {
|
||||
this->last_valid_frame_ = App.get_loop_component_start_time();
|
||||
this->last_init_attempt_ = this->last_valid_frame_;
|
||||
}
|
||||
|
||||
void APC1Component::set_active_mode() {
|
||||
if (!this->active_mode_) {
|
||||
this->arm_watchdog_();
|
||||
}
|
||||
this->active_mode_ = true;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE);
|
||||
}
|
||||
|
||||
void APC1Component::set_passive_mode() {
|
||||
this->active_mode_ = false;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE);
|
||||
}
|
||||
|
||||
void APC1Component::request_measurement() {
|
||||
this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000);
|
||||
}
|
||||
|
||||
void APC1Component::set_idle_mode() {
|
||||
this->idle_ = true;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE);
|
||||
}
|
||||
|
||||
void APC1Component::set_measurement_mode() {
|
||||
this->idle_ = false;
|
||||
this->arm_watchdog_();
|
||||
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD);
|
||||
}
|
||||
|
||||
void APC1Component::send_command_(APC1Command cmd, uint16_t data) {
|
||||
std::array<uint8_t, APC1_COMMAND_FRAME_SIZE> buf{
|
||||
APC1_HEADER_1,
|
||||
APC1_HEADER_2,
|
||||
static_cast<uint8_t>(cmd),
|
||||
static_cast<uint8_t>((data >> 8) & 0xFF),
|
||||
static_cast<uint8_t>(data & 0xFF),
|
||||
0,
|
||||
0,
|
||||
};
|
||||
uint16_t checksum = apc1_checksum(buf.data(), 5);
|
||||
buf[5] = static_cast<uint8_t>((checksum >> 8) & 0xFF);
|
||||
buf[6] = static_cast<uint8_t>(checksum & 0xFF);
|
||||
this->write_array(buf);
|
||||
}
|
||||
|
||||
} // namespace esphome::apc1
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::apc1 {
|
||||
|
||||
static constexpr uint8_t APC1_HEADER_1 = 0x42;
|
||||
static constexpr uint8_t APC1_HEADER_2 = 0x4D;
|
||||
static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64;
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23;
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8;
|
||||
static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7;
|
||||
|
||||
enum class APC1Command : uint8_t {
|
||||
APC1_COMMAND_MEASUREMENT_MODE = 0xE1,
|
||||
APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2,
|
||||
APC1_COMMAND_OPERATION_MODE = 0xE4,
|
||||
APC1_COMMAND_READ_SENSOR_VERSION = 0xE9,
|
||||
};
|
||||
|
||||
static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000;
|
||||
static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001;
|
||||
|
||||
static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000;
|
||||
static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001;
|
||||
|
||||
class APC1Component final : public uart::UARTDevice, public Component {
|
||||
SUB_SENSOR(pm_1_0)
|
||||
SUB_SENSOR(pm_2_5)
|
||||
SUB_SENSOR(pm_10_0)
|
||||
SUB_SENSOR(pm_1_0_std)
|
||||
SUB_SENSOR(pm_2_5_std)
|
||||
SUB_SENSOR(pm_10_0_std)
|
||||
|
||||
SUB_SENSOR(pm_0_3um)
|
||||
SUB_SENSOR(pm_0_5um)
|
||||
SUB_SENSOR(pm_1_0um)
|
||||
SUB_SENSOR(pm_2_5um)
|
||||
SUB_SENSOR(pm_5_0um)
|
||||
SUB_SENSOR(pm_10_0um)
|
||||
|
||||
SUB_SENSOR(tvoc)
|
||||
SUB_SENSOR(eco2)
|
||||
SUB_SENSOR(aqi)
|
||||
|
||||
SUB_SENSOR(temperature)
|
||||
SUB_SENSOR(humidity)
|
||||
SUB_SENSOR(raw_temperature)
|
||||
SUB_SENSOR(raw_humidity)
|
||||
|
||||
SUB_SENSOR(rs0)
|
||||
SUB_SENSOR(rs2)
|
||||
SUB_SENSOR(rs3)
|
||||
|
||||
SUB_SENSOR(error_code)
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void loop() override;
|
||||
|
||||
void set_active_mode();
|
||||
void set_passive_mode();
|
||||
void request_measurement();
|
||||
void set_idle_mode();
|
||||
void set_measurement_mode();
|
||||
|
||||
void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; }
|
||||
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
|
||||
|
||||
protected:
|
||||
void send_command_(APC1Command cmd, uint16_t data);
|
||||
void initialize_device_();
|
||||
/// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot
|
||||
void arm_watchdog_();
|
||||
void parse_frame_(uint16_t total_len);
|
||||
void parse_measurement_frame_();
|
||||
void parse_device_info_frame_();
|
||||
|
||||
GPIOPin *set_pin_{nullptr};
|
||||
GPIOPin *reset_pin_{nullptr};
|
||||
uint32_t last_transmission_{0};
|
||||
uint32_t last_valid_frame_{0};
|
||||
uint32_t last_init_attempt_{0};
|
||||
std::array<uint8_t, APC1_FRAME_SIZE_MEASUREMENT> rx_buffer_{};
|
||||
uint8_t rx_index_{0};
|
||||
uint8_t last_error_code_{0xFF};
|
||||
bool active_mode_{true};
|
||||
bool idle_{false};
|
||||
bool gas_warming_up_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::apc1
|
||||
@@ -0,0 +1,222 @@
|
||||
from esphome import automation, pins
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, uart
|
||||
from esphome.components.const import CONF_AQI
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ECO2,
|
||||
CONF_HUMIDITY,
|
||||
CONF_ID,
|
||||
CONF_PM_0_3UM,
|
||||
CONF_PM_0_5UM,
|
||||
CONF_PM_1_0,
|
||||
CONF_PM_1_0_STD,
|
||||
CONF_PM_1_0UM,
|
||||
CONF_PM_2_5,
|
||||
CONF_PM_2_5_STD,
|
||||
CONF_PM_2_5UM,
|
||||
CONF_PM_5_0UM,
|
||||
CONF_PM_10_0,
|
||||
CONF_PM_10_0_STD,
|
||||
CONF_PM_10_0UM,
|
||||
CONF_RESET_PIN,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TVOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_CHEMICAL_WEAPON,
|
||||
ICON_MOLECULE_CO2,
|
||||
ICON_OMEGA,
|
||||
ICON_RADIATOR,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_COUNT_DECILITRE,
|
||||
UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
UNIT_OHM,
|
||||
UNIT_PARTS_PER_BILLION,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@ademuri"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
CONF_SET_PIN = "set_pin"
|
||||
CONF_RAW_TEMPERATURE = "raw_temperature"
|
||||
CONF_RAW_HUMIDITY = "raw_humidity"
|
||||
CONF_RS0 = "rs0"
|
||||
CONF_RS2 = "rs2"
|
||||
CONF_RS3 = "rs3"
|
||||
CONF_ERROR_CODE = "error_code"
|
||||
|
||||
apc1_ns = cg.esphome_ns.namespace("apc1")
|
||||
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
|
||||
|
||||
|
||||
def _pm_mass_schema(device_class: str) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
device_class=device_class,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
|
||||
_PM_COUNT_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_COUNT_DECILITRE,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_TEMPERATURE_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_HUMIDITY_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_RESISTANCE_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_OHM,
|
||||
icon=ICON_OMEGA,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(APC1Component),
|
||||
cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1),
|
||||
cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25),
|
||||
cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10),
|
||||
cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1),
|
||||
cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25),
|
||||
cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10),
|
||||
cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_TVOC): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_BILLION,
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_ECO2): 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_AQI): sensor.sensor_schema(
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA,
|
||||
cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA,
|
||||
cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA,
|
||||
cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA,
|
||||
cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema(
|
||||
icon="mdi:alert-circle-outline",
|
||||
accuracy_decimals=0,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
schema = uart.final_validate_device_schema(
|
||||
"apc1", baud_rate=9600, require_rx=True, require_tx=True
|
||||
)
|
||||
schema(config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor)
|
||||
await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor)
|
||||
await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor)
|
||||
await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor)
|
||||
await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor)
|
||||
await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor)
|
||||
await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor)
|
||||
await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor)
|
||||
await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor)
|
||||
await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor)
|
||||
await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor)
|
||||
await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor)
|
||||
await sensors(CONF_TVOC, var.set_tvoc_sensor)
|
||||
await sensors(CONF_ECO2, var.set_eco2_sensor)
|
||||
await sensors(CONF_AQI, var.set_aqi_sensor)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor)
|
||||
await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor)
|
||||
await sensors(CONF_RS0, var.set_rs0_sensor)
|
||||
await sensors(CONF_RS2, var.set_rs2_sensor)
|
||||
await sensors(CONF_RS3, var.set_rs3_sensor)
|
||||
await sensors(CONF_ERROR_CODE, var.set_error_code_sensor)
|
||||
|
||||
if (set_pin_config := config.get(CONF_SET_PIN)) is not None:
|
||||
set_pin = await cg.gpio_pin_expression(set_pin_config)
|
||||
cg.add(var.set_set_pin(set_pin))
|
||||
|
||||
if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None:
|
||||
reset_pin = await cg.gpio_pin_expression(reset_pin_config)
|
||||
cg.add(var.set_reset_pin(reset_pin))
|
||||
|
||||
|
||||
APC1_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(APC1Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
for _name, _call in (
|
||||
("apc1.set_active_mode", "set_active_mode()"),
|
||||
("apc1.set_passive_mode", "set_passive_mode()"),
|
||||
("apc1.request_measurement", "request_measurement()"),
|
||||
("apc1.set_idle_mode", "set_idle_mode()"),
|
||||
("apc1.set_measurement_mode", "set_measurement_mode()"),
|
||||
):
|
||||
automation.register_apply_action(
|
||||
_name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call)
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
sensor:
|
||||
- platform: apc1
|
||||
id: apc1_sensor
|
||||
set_pin: ${set_pin}
|
||||
reset_pin: ${reset_pin}
|
||||
pm_1_0:
|
||||
name: "APC1 PM1.0"
|
||||
pm_2_5:
|
||||
name: "APC1 PM2.5"
|
||||
filters:
|
||||
- throttle_average: 10s
|
||||
pm_10_0:
|
||||
name: "APC1 PM10"
|
||||
pm_1_0_std:
|
||||
name: "APC1 PM1.0 Std"
|
||||
pm_2_5_std:
|
||||
name: "APC1 PM2.5 Std"
|
||||
pm_10_0_std:
|
||||
name: "APC1 PM10 Std"
|
||||
pm_0_3um:
|
||||
name: "APC1 Particles >0.3um"
|
||||
pm_0_5um:
|
||||
name: "APC1 Particles >0.5um"
|
||||
pm_1_0um:
|
||||
name: "APC1 Particles >1.0um"
|
||||
pm_2_5um:
|
||||
name: "APC1 Particles >2.5um"
|
||||
pm_5_0um:
|
||||
name: "APC1 Particles >5.0um"
|
||||
pm_10_0um:
|
||||
name: "APC1 Particles >10um"
|
||||
tvoc:
|
||||
name: "APC1 TVOC"
|
||||
eco2:
|
||||
name: "APC1 eCO2"
|
||||
aqi:
|
||||
name: "APC1 AQI"
|
||||
temperature:
|
||||
name: "APC1 Temperature"
|
||||
humidity:
|
||||
name: "APC1 Humidity"
|
||||
raw_temperature:
|
||||
name: "APC1 Raw Temperature"
|
||||
raw_humidity:
|
||||
name: "APC1 Raw Humidity"
|
||||
rs0:
|
||||
name: "APC1 RS0"
|
||||
rs2:
|
||||
name: "APC1 MOX RS2"
|
||||
rs3:
|
||||
name: "APC1 MOX RS3"
|
||||
error_code:
|
||||
name: "APC1 Error Code"
|
||||
|
||||
interval:
|
||||
- interval: 60s
|
||||
then:
|
||||
- apc1.set_active_mode: apc1_sensor
|
||||
- apc1.set_passive_mode: apc1_sensor
|
||||
- apc1.request_measurement: apc1_sensor
|
||||
- apc1.set_idle_mode: apc1_sensor
|
||||
- apc1.set_measurement_mode: apc1_sensor
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
set_pin: GPIO32
|
||||
reset_pin: GPIO14
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
apc1: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
set_pin: GPIO4
|
||||
reset_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
apc1: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
set_pin: GPIO2
|
||||
reset_pin: GPIO3
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
apc1: !include common.yaml
|
||||
Reference in New Issue
Block a user