From 318e4b79c110917303e49aade9d6fa13601ace63 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 12 Oct 2026 17:38:34 -0600 Subject: [PATCH] [apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/apc1/__init__.py | 3 + esphome/components/apc1/apc1.cpp | 352 ++++++++++++++++++++ esphome/components/apc1/apc1.h | 99 ++++++ esphome/components/apc1/sensor.py | 222 ++++++++++++ tests/components/apc1/common.yaml | 62 ++++ tests/components/apc1/test.esp32-idf.yaml | 7 + tests/components/apc1/test.esp8266-ard.yaml | 7 + tests/components/apc1/test.rp2040-ard.yaml | 7 + 9 files changed, 760 insertions(+) create mode 100644 esphome/components/apc1/__init__.py create mode 100644 esphome/components/apc1/apc1.cpp create mode 100644 esphome/components/apc1/apc1.h create mode 100644 esphome/components/apc1/sensor.py create mode 100644 tests/components/apc1/common.yaml create mode 100644 tests/components/apc1/test.esp32-idf.yaml create mode 100644 tests/components/apc1/test.esp8266-ard.yaml create mode 100644 tests/components/apc1/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f7875eb1407..fab41f5b1fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 00000000000..355ecc23058 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 00000000000..beca9d85815 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -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 +#include + +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(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(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(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 buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 00000000000..850738ead6a --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -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 + +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 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 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 00000000000..b64a49903f2 --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -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) + ) diff --git a/tests/components/apc1/common.yaml b/tests/components/apc1/common.yaml new file mode 100644 index 00000000000..496f587e42a --- /dev/null +++ b/tests/components/apc1/common.yaml @@ -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 diff --git a/tests/components/apc1/test.esp32-idf.yaml b/tests/components/apc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..a8fdc5956dc --- /dev/null +++ b/tests/components/apc1/test.esp32-idf.yaml @@ -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 diff --git a/tests/components/apc1/test.esp8266-ard.yaml b/tests/components/apc1/test.esp8266-ard.yaml new file mode 100644 index 00000000000..746829bea79 --- /dev/null +++ b/tests/components/apc1/test.esp8266-ard.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 diff --git a/tests/components/apc1/test.rp2040-ard.yaml b/tests/components/apc1/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e1dc80743f5 --- /dev/null +++ b/tests/components/apc1/test.rp2040-ard.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