From c74a9da996e2e7b36d5d2dc59f634eb3011b50bc Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:54:25 +0200 Subject: [PATCH] [veml6040] Add VEML6040 RGBW colour sensor (#20812) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/veml6040/__init__.py | 1 + esphome/components/veml6040/sensor.py | 98 ++++++++++++++ esphome/components/veml6040/veml6040.cpp | 120 ++++++++++++++++++ esphome/components/veml6040/veml6040.h | 55 ++++++++ tests/components/veml6040/common.yaml | 18 +++ tests/components/veml6040/test.esp32-idf.yaml | 3 + .../components/veml6040/test.esp8266-ard.yaml | 3 + .../components/veml6040/test.rp2040-ard.yaml | 3 + 9 files changed, 302 insertions(+) create mode 100644 esphome/components/veml6040/__init__.py create mode 100644 esphome/components/veml6040/sensor.py create mode 100644 esphome/components/veml6040/veml6040.cpp create mode 100644 esphome/components/veml6040/veml6040.h create mode 100644 tests/components/veml6040/common.yaml create mode 100644 tests/components/veml6040/test.esp32-idf.yaml create mode 100644 tests/components/veml6040/test.esp8266-ard.yaml create mode 100644 tests/components/veml6040/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 523cdb539ca..a792cc6984c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -695,6 +695,7 @@ esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 +esphome/components/veml6040/* @aalaei esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core esphome/components/vevor_dtx/* @Bofr5555 diff --git a/esphome/components/veml6040/__init__.py b/esphome/components/veml6040/__init__.py new file mode 100644 index 00000000000..bd7aa9649a4 --- /dev/null +++ b/esphome/components/veml6040/__init__.py @@ -0,0 +1 @@ +DOMAIN = "veml6040" diff --git a/esphome/components/veml6040/sensor.py b/esphome/components/veml6040/sensor.py new file mode 100644 index 00000000000..02d6df9affd --- /dev/null +++ b/esphome/components/veml6040/sensor.py @@ -0,0 +1,98 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ILLUMINANCE, + CONF_INTEGRATION_TIME, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@aalaei"] +DEPENDENCIES = ["i2c"] + +CONF_COLOR_TEMPERATURE_OFFSET = "color_temperature_offset" +CONF_WHITE_CHANNEL = "white_channel" + +veml6040_ns = cg.esphome_ns.namespace("veml6040") +VEML6040Component = veml6040_ns.class_( + "VEML6040Component", cg.PollingComponent, i2c.I2CDevice +) + +VEML6040IntegrationTime = veml6040_ns.enum("VEML6040IntegrationTime") +VEML6040_INTEGRATION_TIMES = { + "40ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_40MS, + "80ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_80MS, + "160ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_160MS, + "320ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_320MS, + "640ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_640MS, + "1280ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_1280MS, +} + +color_channel_schema = sensor.sensor_schema( + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VEML6040Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_WHITE_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_INTEGRATION_TIME, default="160ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.enum(VEML6040_INTEGRATION_TIMES, lower=True), + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + cv.Optional(CONF_COLOR_TEMPERATURE_OFFSET, default=0.5): cv.float_, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x10)) +) + + +async def to_code(config: ConfigType) -> None: + var = await i2c.new_i2c_component( + config, + config[CONF_INTEGRATION_TIME], + config[CONF_GLASS_ATTENUATION_FACTOR], + config[CONF_COLOR_TEMPERATURE_OFFSET], + ) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, var.set_red_sensor) + await sensors(CONF_GREEN_CHANNEL, var.set_green_sensor) + await sensors(CONF_BLUE_CHANNEL, var.set_blue_sensor) + await sensors(CONF_WHITE_CHANNEL, var.set_white_sensor) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, var.set_color_temperature_sensor) diff --git a/esphome/components/veml6040/veml6040.cpp b/esphome/components/veml6040/veml6040.cpp new file mode 100644 index 00000000000..f4c3819de90 --- /dev/null +++ b/esphome/components/veml6040/veml6040.cpp @@ -0,0 +1,120 @@ +#include "veml6040.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::veml6040 { + +ESPHOME_LOG_TAG(TAG, "veml6040"); + +static constexpr uint8_t CONFIG_REG = 0x00; +static constexpr uint8_t R_DATA_REG = 0x08; +static constexpr uint8_t G_DATA_REG = 0x09; +static constexpr uint8_t B_DATA_REG = 0x0A; +static constexpr uint8_t W_DATA_REG = 0x0B; + +static constexpr uint8_t CONFIG_REG_IT_BIT = 4; + +static constexpr uint16_t MAX_COUNTS = 0xFFFF; + +// Lux per green count at 40 ms integration time; halves for each longer integration time step +static constexpr float LUX_PER_COUNT_40MS = 0.25168f; + +// Correlated colour temperature fit from the Vishay application note "Designing the VEML6040 into an +// Application": CCT = A * ((R - B) / G + offset) ^ EXPONENT. The note gives offset = 0.5 in open air and says it +// changes behind cover glass, so it is configurable. +static constexpr float CCT_A = 4278.6f; +static constexpr float CCT_EXPONENT = -1.2455f; + +void VEML6040Component::setup() { + // Auto mode (AF = 0, TRIG = 0) with shutdown bit cleared: the device measures continuously. + // The high byte of the configuration register is reserved and must be written as zero. + const uint8_t config[] = {static_cast(this->integration_time_ << CONFIG_REG_IT_BIT), 0x00}; + if (this->write_register(CONFIG_REG, config, sizeof config) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + // The data registers hold zeros until the first integration period has finished; publish as soon as it has + this->set_timeout(this->integration_time_ms_(), [this]() { + this->data_ready_ = true; + this->update(); + }); +} + +void VEML6040Component::dump_config() { + ESP_LOGCONFIG(TAG, + "VEML6040:\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f\n" + " Color temperature offset: %.3f", + this->integration_time_ms_(), this->glass_attenuation_factor_, this->color_temperature_offset_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Red channel", this->red_sensor_); + LOG_SENSOR(" ", "Green channel", this->green_sensor_); + LOG_SENSOR(" ", "Blue channel", this->blue_sensor_); + LOG_SENSOR(" ", "White channel", this->white_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_); +} + +bool VEML6040Component::read_channel_(uint8_t reg, uint16_t &counts) { + uint8_t data[2]; + if (this->read_register(reg, data, sizeof data) != i2c::ERROR_OK) + return false; + counts = encode_uint16(data[1], data[0]); + return true; +} + +void VEML6040Component::update() { + if (!this->data_ready_) + return; + + uint16_t red, green, blue, white; + if (!this->read_channel_(R_DATA_REG, red) || !this->read_channel_(G_DATA_REG, green) || + !this->read_channel_(B_DATA_REG, blue) || !this->read_channel_(W_DATA_REG, white)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + ESP_LOGV(TAG, "Counts: red=%u green=%u blue=%u white=%u", red, green, blue, white); + + if (this->red_sensor_ != nullptr) + this->red_sensor_->publish_state(red); + if (this->green_sensor_ != nullptr) + this->green_sensor_->publish_state(green); + if (this->blue_sensor_ != nullptr) + this->blue_sensor_->publish_state(blue); + if (this->white_sensor_ != nullptr) + this->white_sensor_->publish_state(white); + + const bool saturated = red == MAX_COUNTS || green == MAX_COUNTS || blue == MAX_COUNTS; + if (saturated) { + ESP_LOGW(TAG, "Sensor saturated; use a shorter integration time"); + } + + if (this->illuminance_sensor_ != nullptr) { + float lux = NAN; + if (green != MAX_COUNTS) { + const float lux_per_count = LUX_PER_COUNT_40MS / static_cast(1u << this->integration_time_); + lux = green * lux_per_count * this->glass_attenuation_factor_; + } + this->illuminance_sensor_->publish_state(lux); + } + + if (this->color_temperature_sensor_ != nullptr) { + float cct = NAN; + if (!saturated && green != 0) { + const float index = + (static_cast(red) - static_cast(blue)) / green + this->color_temperature_offset_; + if (index > 0.0f) + cct = CCT_A * powf(index, CCT_EXPONENT); + } + this->color_temperature_sensor_->publish_state(cct); + } +} + +} // namespace esphome::veml6040 diff --git a/esphome/components/veml6040/veml6040.h b/esphome/components/veml6040/veml6040.h new file mode 100644 index 00000000000..971ce24c684 --- /dev/null +++ b/esphome/components/veml6040/veml6040.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::veml6040 { + +// Register values for the IT bits of the configuration register, taken from the VEML6040 datasheet. +// Each step doubles the integration time and halves the lux per count. +enum VEML6040IntegrationTime : uint8_t { + VEML6040_INTEGRATION_TIME_40MS = 0b000, + VEML6040_INTEGRATION_TIME_80MS = 0b001, + VEML6040_INTEGRATION_TIME_160MS = 0b010, + VEML6040_INTEGRATION_TIME_320MS = 0b011, + VEML6040_INTEGRATION_TIME_640MS = 0b100, + VEML6040_INTEGRATION_TIME_1280MS = 0b101, +}; + +class VEML6040Component final : public PollingComponent, public i2c::I2CDevice { + public: + VEML6040Component(VEML6040IntegrationTime integration_time, float glass_attenuation_factor, + float color_temperature_offset) + : glass_attenuation_factor_(glass_attenuation_factor), + color_temperature_offset_(color_temperature_offset), + integration_time_(integration_time) {} + + void setup() override; + void dump_config() override; + void update() override; + + void set_red_sensor(sensor::Sensor *sensor) { this->red_sensor_ = sensor; } + void set_green_sensor(sensor::Sensor *sensor) { this->green_sensor_ = sensor; } + void set_blue_sensor(sensor::Sensor *sensor) { this->blue_sensor_ = sensor; } + void set_white_sensor(sensor::Sensor *sensor) { this->white_sensor_ = sensor; } + void set_illuminance_sensor(sensor::Sensor *sensor) { this->illuminance_sensor_ = sensor; } + void set_color_temperature_sensor(sensor::Sensor *sensor) { this->color_temperature_sensor_ = sensor; } + + protected: + bool read_channel_(uint8_t reg, uint16_t &counts); + uint32_t integration_time_ms_() const { return 40u << this->integration_time_; } + + sensor::Sensor *red_sensor_{nullptr}; + sensor::Sensor *green_sensor_{nullptr}; + sensor::Sensor *blue_sensor_{nullptr}; + sensor::Sensor *white_sensor_{nullptr}; + sensor::Sensor *illuminance_sensor_{nullptr}; + sensor::Sensor *color_temperature_sensor_{nullptr}; + const float glass_attenuation_factor_; + const float color_temperature_offset_; + const VEML6040IntegrationTime integration_time_; + bool data_ready_{false}; +}; + +} // namespace esphome::veml6040 diff --git a/tests/components/veml6040/common.yaml b/tests/components/veml6040/common.yaml new file mode 100644 index 00000000000..c563a3a58ba --- /dev/null +++ b/tests/components/veml6040/common.yaml @@ -0,0 +1,18 @@ +sensor: + - platform: veml6040 + i2c_id: i2c_bus + integration_time: 160ms + glass_attenuation_factor: 1.2 + color_temperature_offset: 0.42 + red_channel: + name: VEML6040 Red + green_channel: + name: VEML6040 Green + blue_channel: + name: VEML6040 Blue + white_channel: + name: VEML6040 White + illuminance: + name: VEML6040 Illuminance + color_temperature: + name: VEML6040 Color Temperature diff --git a/tests/components/veml6040/test.esp32-idf.yaml b/tests/components/veml6040/test.esp32-idf.yaml new file mode 100644 index 00000000000..e454bc981fc --- /dev/null +++ b/tests/components/veml6040/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + veml6040: !include common.yaml diff --git a/tests/components/veml6040/test.esp8266-ard.yaml b/tests/components/veml6040/test.esp8266-ard.yaml new file mode 100644 index 00000000000..46e499faabd --- /dev/null +++ b/tests/components/veml6040/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + veml6040: !include common.yaml diff --git a/tests/components/veml6040/test.rp2040-ard.yaml b/tests/components/veml6040/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1c60e875d0b --- /dev/null +++ b/tests/components/veml6040/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + veml6040: !include common.yaml