diff --git a/CODEOWNERS b/CODEOWNERS index 8fbbacef59..4a7ce357d3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -348,6 +348,7 @@ esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti +esphome/components/sc01_o3/* @Tthecreator esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core esphome/components/sdl/* @clydebarrow diff --git a/esphome/components/sc01_o3/__init__.py b/esphome/components/sc01_o3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/sc01_o3/sc01_o3.cpp b/esphome/components/sc01_o3/sc01_o3.cpp new file mode 100644 index 0000000000..ee7b53289e --- /dev/null +++ b/esphome/components/sc01_o3/sc01_o3.cpp @@ -0,0 +1,114 @@ +#include "sc01_o3.h" + +static const char *const TAG = "SC01O3Sensor"; + +namespace esphome { +namespace sc01_o3 { + +// The position of various data points in the return packet, excluding the first 0xFF byte. +constexpr uint8_t GAS_NAME_POS = 0; +constexpr uint8_t UNIT_POS = 1; +constexpr uint8_t GAS_CONCENTRATION_HIGH_POS = 3; +constexpr uint8_t GAS_CONCENTRATION_LOW_POS = 4; +constexpr uint8_t CHECKSUM_POS = 7; + +// Defined constant +constexpr uint8_t O3_GAS_TYPE = 0x17; +constexpr uint8_t UNIT_PPB = 0x04; +constexpr uint8_t START_BYTE_VALUE = 0xFF; + +constexpr std::array SET_QNA_MODE_COMMAND = { + 0x01, // Reserved + 0x78, // Change order + 0x41, // Q & A mode (This is where we have to ask the device for a + // measurement, instead of the measurement happening every second.) + 0x00, 0x00, 0x00, 0x00, // Reserved +}; + +constexpr std::array ASK_FOR_DATA_COMMAND = { + 0x01, // Reserved + 0x86, // Order + 0x00, 0x00, 0x00, 0x00, 0x00, // Reserved +}; + +constexpr std::array DATA_PREPEND_BYTES = { + START_BYTE_VALUE, // Start byte +}; + +void SC01O3Sensor::setup() { + if (!this->is_initialized_) { + this->set_qna_mode_(); + } +} + +void SC01O3Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SC01 O3 sensor!"); } + +uint8_t SC01O3Sensor::calculate_checksum(const uint8_t *data, uint8_t len) { + // Calculation is to add up all bytes (except first 0xFF) bytes, then negate it, and add one. + uint8_t out = 0; + for (uint8_t i = 0; i < len; ++i) { + out += data[i]; + } + out = (~out) + 1; + return out; +} + +void SC01O3Sensor::send_command_(const std::array data) { + this->write_array(DATA_PREPEND_BYTES); + this->write_array(data); + uint8_t checksum = calculate_checksum(data.data(), data.size()); + this->write_array(&checksum, 1); +} + +void SC01O3Sensor::set_qna_mode_() { + // Called once during setup + send_command_(SET_QNA_MODE_COMMAND); + ESP_LOGI(TAG, "O3 Sensor setup complete."); + this->is_initialized_ = true; +} + +void SC01O3Sensor::update() { + if (!this->is_initialized_) { + set_qna_mode_(); + } + ESP_LOGD(TAG, "Update...."); + send_command_(SET_QNA_MODE_COMMAND); + send_command_(ASK_FOR_DATA_COMMAND); +} + +void SC01O3Sensor::loop() { + // ESP_LOGI(TAG, "Loop...."); + if (!this->is_initialized_) { + set_qna_mode_(); + } + // Polling function to read data from UART + while (this->available() >= 9) { + // Read the first byte and check for the start byte (0xFF) + uint8_t first_byte = this->read(); + if (first_byte != START_BYTE_VALUE) { + continue; // Skip and search for the next start byte + } + // If we find START_BYTE_VALUE, attempt to read the rest of the packet + + uint8_t buffer[8]; // Excluding the start byte + this->read_array(buffer, 8); + + ESP_LOGD(TAG, "Received data: %x %x %x %x %x %x %x %x", buffer[0], buffer[1], buffer[2], buffer[3], buffer[4], + buffer[5], buffer[6], buffer[7]); + + // Validate packet + if (buffer[GAS_NAME_POS] == O3_GAS_TYPE && buffer[UNIT_POS] == UNIT_PPB && + buffer[CHECKSUM_POS] == calculate_checksum(buffer, 7)) { // The gas name and unit should be consistent. + uint16_t concentration = + (static_cast(buffer[GAS_CONCENTRATION_HIGH_POS]) << 8) | buffer[GAS_CONCENTRATION_LOW_POS]; + state = concentration; + this->publish_state(concentration); + ESP_LOGI(TAG, "Valid data: %u ppb", concentration); + } else { + ESP_LOGW(TAG, "Invalid data received after start byte"); + } + } +} + +} // namespace sc01_o3 +} // namespace esphome diff --git a/esphome/components/sc01_o3/sc01_o3.h b/esphome/components/sc01_o3/sc01_o3.h new file mode 100644 index 0000000000..7cc885a324 --- /dev/null +++ b/esphome/components/sc01_o3/sc01_o3.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/components/uart/uart.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/defines.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace sc01_o3 { + +class SC01O3Sensor : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void update() override; + void loop() override; + void dump_config() override; + + uint16_t state = 0; + + protected: + bool is_initialized_ = false; + void set_qna_mode_(); + static uint8_t calculate_checksum(const uint8_t *data, uint8_t len); + void send_command_(std::array data); +}; + +} // namespace sc01_o3 +} // namespace esphome diff --git a/esphome/components/sc01_o3/sensor.py b/esphome/components/sc01_o3/sensor.py new file mode 100644 index 0000000000..24d873efb4 --- /dev/null +++ b/esphome/components/sc01_o3/sensor.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_OZONE, + ICON_MOLECULE_CO2, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_BILLION, +) + +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@Tthecreator"] + +SC01O3_ns = cg.esphome_ns.namespace("sc01_o3") +SC01O3Component = SC01O3_ns.class_( + "SC01O3Sensor", sensor.Sensor, cg.PollingComponent, uart.UARTDevice +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_OZONE, + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend( + { + cv.GenerateID(): cv.declare_id(SC01O3Component), + cv.Optional( + CONF_UPDATE_INTERVAL, "1000ms" + ): cv.positive_time_period_milliseconds, + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sc01_o3", + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity=None, + stop_bits=1, +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + # var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/tests/components/sc01_o3/test.esp32-ard.yaml b/tests/components/sc01_o3/test.esp32-ard.yaml new file mode 100644 index 0000000000..78839d365f --- /dev/null +++ b/tests/components/sc01_o3/test.esp32-ard.yaml @@ -0,0 +1,14 @@ +uart: + tx_pin: GPIO021 + rx_pin: GPIO016 + baud_rate: 9600 + stop_bits: 1 + id: uart_bus + +sensor: + - platform: sc01_o3 + id: o3_sensor + uart_id: uart_bus + name: "Ozone Concentration" + icon: "mdi:chemical-weapon" + update_interval: 5s