diff --git a/CODEOWNERS b/CODEOWNERS index 2aa0656343..6728e76bba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -213,6 +213,7 @@ esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb +esphome/components/hdc302x/* @joshuasing esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal diff --git a/esphome/components/hdc302x/__init__.py b/esphome/components/hdc302x/__init__.py new file mode 100644 index 0000000000..d8165281bf --- /dev/null +++ b/esphome/components/hdc302x/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@joshuasing"] diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp new file mode 100644 index 0000000000..b50d34169a --- /dev/null +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -0,0 +1,171 @@ +#include "hdc302x.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::hdc302x { + +static const char *const TAG = "hdc302x.sensor"; + +// Commands (per datasheet Table 7-4) +static const uint8_t HDC302X_CMD_SOFT_RESET[2] = {0x30, 0xa2}; +static const uint8_t HDC302X_CMD_CLEAR_STATUS_REGISTER[2] = {0x30, 0x41}; + +static const uint8_t HDC302X_CMD_TRIGGER_MSB = 0x24; + +static const uint8_t HDC302X_CMD_HEATER_ENABLE[2] = {0x30, 0x6d}; +static const uint8_t HDC302X_CMD_HEATER_DISABLE[2] = {0x30, 0x66}; +static const uint8_t HDC302X_CMD_HEATER_CONFIGURE[2] = {0x30, 0x6e}; + +void HDC302XComponent::setup() { + // Soft reset the device + if (this->write(HDC302X_CMD_SOFT_RESET, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Soft reset failed")); + return; + } + // Delay SensorRR (reset ready), per datasheet, 6.5. + delay(3); + + // Clear status register + if (this->write(HDC302X_CMD_CLEAR_STATUS_REGISTER, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Clear status failed")); + return; + } +} + +void HDC302XComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "HDC302x:\n" + " Heater: %s", + this->heater_active_ ? "active" : "inactive"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Temperature", this->temp_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); +} + +void HDC302XComponent::update() { + uint8_t cmd[] = { + HDC302X_CMD_TRIGGER_MSB, + this->power_mode_, + }; + if (this->write(cmd, 2) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Read data after ADC conversion has completed + this->set_timeout(this->conversion_delay_ms_(), [this]() { this->read_data_(); }); +} + +void HDC302XComponent::start_heater(uint16_t power, uint32_t duration_ms) { + if (!this->disable_heater_()) { + ESP_LOGD(TAG, "Heater disable before start failed"); + } + if (!this->configure_heater_(power) || !this->enable_heater_()) { + ESP_LOGW(TAG, "Heater start failed"); + return; + } + this->heater_active_ = true; + this->cancel_timeout("heater_off"); + if (duration_ms > 0) { + this->set_timeout("heater_off", duration_ms, [this]() { this->stop_heater(); }); + } +} + +void HDC302XComponent::stop_heater() { + this->cancel_timeout("heater_off"); + if (!this->disable_heater_()) { + ESP_LOGW(TAG, "Heater stop failed"); + } + this->heater_active_ = false; +} + +bool HDC302XComponent::enable_heater_() { + if (this->write(HDC302X_CMD_HEATER_ENABLE, 2) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Enable heater failed"); + return false; + } + return true; +} + +bool HDC302XComponent::configure_heater_(uint16_t power_level) { + if (power_level > 0x3fff) { + ESP_LOGW(TAG, "Heater power 0x%04x exceeds max 0x3fff", power_level); + return false; + } + + // Heater current level config. + uint8_t config[] = { + static_cast((power_level >> 8) & 0xff), // MSB + static_cast(power_level & 0xff) // LSB + }; + + // Configure level of heater current (per datasheet 7.5.7.8). + uint8_t cmd[] = { + HDC302X_CMD_HEATER_CONFIGURE[0], HDC302X_CMD_HEATER_CONFIGURE[1], config[0], config[1], + crc8(config, 2, 0xff, 0x31, true), + }; + if (this->write(cmd, sizeof(cmd)) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Configure heater failed"); + return false; + } + + return true; +} + +bool HDC302XComponent::disable_heater_() { + if (this->write(HDC302X_CMD_HEATER_DISABLE, 2) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Disable heater failed"); + return false; + } + return true; +} + +void HDC302XComponent::read_data_() { + uint8_t buf[6]; + if (this->read(buf, 6) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Check checksums + if (crc8(buf, 2, 0xff, 0x31, true) != buf[2] || crc8(buf + 3, 2, 0xff, 0x31, true) != buf[5]) { + this->status_set_warning(LOG_STR("Read data: invalid CRC")); + return; + } + + this->status_clear_warning(); + + if (this->temp_sensor_ != nullptr) { + uint16_t raw_t = encode_uint16(buf[0], buf[1]); + // Calculate temperature in Celsius per datasheet section 7.3.3. + float temp = -45 + 175 * (float(raw_t) / 65535.0f); + this->temp_sensor_->publish_state(temp); + } + + if (this->humidity_sensor_ != nullptr) { + uint16_t raw_rh = encode_uint16(buf[3], buf[4]); + // Calculate RH% per datasheet section 7.3.3. + float humidity = 100 * (float(raw_rh) / 65535.0f); + this->humidity_sensor_->publish_state(humidity); + } +} + +uint32_t HDC302XComponent::conversion_delay_ms_() { + // ADC conversion delay per datasheet, Table 7-5. - Trigger on Demand + switch (this->power_mode_) { + case HDC302XPowerMode::BALANCED: + return 8; + case HDC302XPowerMode::LOW_POWER: + return 5; + case HDC302XPowerMode::ULTRA_LOW_POWER: + return 4; + case HDC302XPowerMode::HIGH_ACCURACY: + default: + return 13; + } +} + +} // namespace esphome::hdc302x diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h new file mode 100644 index 0000000000..6afea0a8c0 --- /dev/null +++ b/esphome/components/hdc302x/hdc302x.h @@ -0,0 +1,68 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::hdc302x { + +enum HDC302XPowerMode : uint8_t { + HIGH_ACCURACY = 0x00, + BALANCED = 0x0b, + LOW_POWER = 0x16, + ULTRA_LOW_POWER = 0xff, +}; + +/** + HDC302x Temperature and humidity sensor. + + Datasheet: + https://www.ti.com/lit/ds/symlink/hdc3020.pdf + */ +class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void start_heater(uint16_t power, uint32_t duration_ms); + void stop_heater(); + + void set_temp_sensor(sensor::Sensor *temp_sensor) { this->temp_sensor_ = temp_sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + + void set_power_mode(HDC302XPowerMode power_mode) { this->power_mode_ = power_mode; } + + protected: + sensor::Sensor *temp_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + + HDC302XPowerMode power_mode_{HDC302XPowerMode::HIGH_ACCURACY}; + bool heater_active_{false}; + + bool enable_heater_(); + bool configure_heater_(uint16_t power_level); + bool disable_heater_(); + void read_data_(); + uint32_t conversion_delay_ms_(); +}; + +template class HeaterOnAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint16_t, power) + TEMPLATABLE_VALUE(uint32_t, duration) + + void play(const Ts &...x) override { + auto power_val = this->power_.value(x...); + auto duration_val = this->duration_.value(x...); + this->parent_->start_heater(power_val, duration_val); + } +}; + +template class HeaterOffAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_heater(); } +}; + +} // namespace esphome::hdc302x diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py new file mode 100644 index 0000000000..7215a4cfb7 --- /dev/null +++ b/esphome/components/hdc302x/sensor.py @@ -0,0 +1,135 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DURATION, + CONF_HUMIDITY, + CONF_ID, + CONF_POWER, + CONF_POWER_MODE, + CONF_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) + +DEPENDENCIES = ["i2c"] + +hdc302x_ns = cg.esphome_ns.namespace("hdc302x") +HDC302XComponent = hdc302x_ns.class_( + "HDC302XComponent", cg.PollingComponent, i2c.I2CDevice +) + +HDC302XPowerMode = hdc302x_ns.enum("HDC302XPowerMode") +POWER_MODE_OPTIONS = { + "HIGH_ACCURACY": HDC302XPowerMode.HIGH_ACCURACY, + "BALANCED": HDC302XPowerMode.BALANCED, + "LOW_POWER": HDC302XPowerMode.LOW_POWER, + "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, +} + +# Actions +HeaterOnAction = hdc302x_ns.class_("HeaterOnAction", automation.Action) +HeaterOffAction = hdc302x_ns.class_("HeaterOffAction", automation.Action) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDC302XComponent), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + 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, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_MODE, default="HIGH_ACCURACY"): cv.enum( + POWER_MODE_OPTIONS, upper=True + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x44)) # Default address per datasheet, Table 7-2. +) + + +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) + + if temp_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temp_config) + cg.add(var.set_temp_sensor(sens)) + + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity_sensor(sens)) + + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + + +# HDC302x heater power configs, per datasheet Table 7-15. +HDC302X_HEATER_POWER_MAP = { + "QUARTER": 0x009F, + "HALF": 0x03FF, + "FULL": 0x3FFF, +} + + +def heater_power_value(value): + """Accept enum names or raw uint16 values""" + if isinstance(value, cv.Lambda): + return value + if isinstance(value, str): + upper = value.upper() + if upper in HDC302X_HEATER_POWER_MAP: + return HDC302X_HEATER_POWER_MAP[upper] + raise cv.Invalid( + f"Unknown heater power preset: {value}. Use QUARTER, HALF, FULL, or a raw value 0-16383" + ) + return cv.int_range(min=0, max=0x3FFF)(value) + + +HDC302X_ACTION_SCHEMA = maybe_simple_id({cv.GenerateID(): cv.use_id(HDC302XComponent)}) + +HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(HDC302XComponent), + cv.Optional(CONF_POWER, default="QUARTER"): cv.templatable(heater_power_value), + cv.Optional(CONF_DURATION, default="5s"): cv.templatable( + cv.positive_time_period_milliseconds + ), + } +) + + +@automation.register_action( + "hdc302x.heater_on", HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA +) +async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) + cg.add(var.set_power(template_)) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) + cg.add(var.set_duration(template_)) + return var + + +@automation.register_action( + "hdc302x.heater_off", HeaterOffAction, HDC302X_ACTION_SCHEMA +) +async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/tests/components/hdc302x/common.yaml b/tests/components/hdc302x/common.yaml new file mode 100644 index 0000000000..56bb31effe --- /dev/null +++ b/tests/components/hdc302x/common.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + then: + - hdc302x.heater_on: + id: hdc302x_sensor + power: QUARTER + duration: 5s + - hdc302x.heater_off: + id: hdc302x_sensor + +sensor: + - platform: hdc302x + id: hdc302x_sensor + i2c_id: i2c_bus + temperature: + name: Temperature + humidity: + name: Humidity + update_interval: 15s diff --git a/tests/components/hdc302x/test.esp32-idf.yaml b/tests/components/hdc302x/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/hdc302x/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc302x/test.esp8266-ard.yaml b/tests/components/hdc302x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/hdc302x/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc302x/test.rp2040-ard.yaml b/tests/components/hdc302x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/hdc302x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml