diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 6657d76bf4d..bd43f78f01e 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -1,7 +1,14 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_GAIN, CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_CALIBRATION, + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_TYPE, + CONF_UPDATE_INTERVAL, +) from esphome.types import ConfigType CODEOWNERS = ["@latonita", "@mrgnr"] @@ -13,6 +20,9 @@ DOMAIN = "as734x" CONF_AS734X_ID = "as734x_id" CONF_ATIME = "atime" CONF_ASTEP = "astep" +CONF_CHANNEL_CORRECTION = "channel_correction" +CONF_DARK_CURRENT = "dark_current" +CONF_NORMALIZE_BASIC_COUNTS = "normalize_basic_counts" # Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. BANDS_43 = ( @@ -44,11 +54,57 @@ GAINS_41 = { } GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} +Normalization = as734x_ns.enum("Normalization", True) +NORMALIZATIONS = { + "NONE": Normalization.NORMALIZATION_NONE, + "ALL": Normalization.NORMALIZATION_ALL, + "BANDS": Normalization.NORMALIZATION_BANDS, + "CLEAR": Normalization.NORMALIZATION_CLEAR, +} + +# Per-channel correction for the golden device, measured by the manufacturer. +DEFAULT_CHANNEL_CORRECTION_43 = [ + 1.055464349, + 1.043509797, + 1.029576268, + 1.0175052, + 1.00441899, + 0.987356499, + 0.957597044, + 0.995863485, + 1.014628964, + 0.996500814, + 0.933072749, + 1.052236338, + 0.999570232, +] # One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us CYCLE_STEP_SECONDS = 2.78e-6 +def _float_list(length: int) -> cv.All: + """One float per band, so the list length has to match the chip.""" + return cv.All(cv.ensure_list(cv.float_), cv.Length(min=length, max=length)) + + +def _calibration_schema(bands: tuple[str, ...]) -> cv.Schema: + correction = ( + DEFAULT_CHANNEL_CORRECTION_43 if bands == BANDS_43 else [1.0] * len(bands) + ) + return cv.Schema( + { + cv.Optional(CONF_CHANNEL_CORRECTION, default=correction): _float_list( + len(bands) + ), + cv.Optional(CONF_DARK_CURRENT, default=[0.0] * len(bands)): _float_list( + len(bands) + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.positive_float, + } + ) + + def _validate_exposure(cycles: int): """Reject an exposure that is empty or longer than the update interval.""" @@ -67,7 +123,9 @@ def _validate_exposure(cycles: int): return validator -def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: +def _schema( + gains: dict, address: int, cycles: int, bands: tuple[str, ...] +) -> cv.Schema: return cv.All( cv.Schema( { @@ -75,6 +133,10 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), + cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( + NORMALIZATIONS, upper=True + ), } ) .extend(cv.polling_component_schema("60s")) @@ -85,14 +147,25 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: CONFIG_SCHEMA = cv.typed_schema( { - "AS7341": _schema(GAINS_41, 0x39, cycles=2), # two SMUX steps per update - "AS7343": _schema(GAINS_43, 0x39, cycles=3), - "TCS3448": _schema(GAINS_43, 0x59, cycles=3), + # two SMUX steps per update + "AS7341": _schema(GAINS_41, 0x39, cycles=2, bands=BANDS_41), + "AS7343": _schema(GAINS_43, 0x39, cycles=3, bands=BANDS_43), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3, bands=BANDS_43), }, upper=True, ) +def _per_band( + values: list[float], bands: tuple[str, ...], absent: float +) -> list[float]: + """Spread per-band calibration over the 13 slots the C++ side indexes by band.""" + spread = [absent] * len(BANDS_43) + for value, band in zip(values, bands, strict=True): + spread[BANDS_43.index(band)] = value + return spread + + async def to_code(config: ConfigType) -> None: chip = config[CONF_TYPE] cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") @@ -102,3 +175,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) cg.add(var.set_astep(config[CONF_ASTEP])) + cg.add(var.set_basic_counts_normalization(config[CONF_NORMALIZE_BASIC_COUNTS])) + + bands = BANDS_41 if chip == "AS7341" else BANDS_43 + calibration = config[CONF_CALIBRATION] + cg.add( + var.set_channel_correction( + _per_band(calibration[CONF_CHANNEL_CORRECTION], bands, 1.0) + ) + ) + cg.add(var.set_dark_current(_per_band(calibration[CONF_DARK_CURRENT], bands, 0.0))) + cg.add(var.set_glass_attenuation_factor(calibration[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 2a9de8f3df2..2331d89c5fe 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include @@ -20,6 +21,8 @@ static constexpr uint8_t CFG0_REG_BANK = 0x10; static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set static constexpr uint8_t STATUS2_AVALID = 0x40; static constexpr uint8_t ASTATUS_ASAT = 0x80; +static constexpr uint8_t LED_ACT = 0x80; // LED driver on, in the chip's LED register +static constexpr uint8_t CONFIG_LED_SEL = 0x08; static constexpr uint8_t AS7341_CFG6 = 0xAF; static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain @@ -40,6 +43,9 @@ static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; static constexpr uint8_t MAX_SMUX_LEN = 20; static constexpr uint8_t MAX_ADC_SLOTS = 18; static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; +static constexpr uint32_t MAX_ADC_COUNT = 65535; + +PROGMEM_STRING_TABLE(BandNames, "F1", "F2", "FZ", "F3", "F4", "FY", "F5", "FXL", "F6", "F7", "F8", "NIR", "Clear"); #ifdef USE_AS7341 static const char AS7341_NAME[] PROGMEM = "AS7341"; @@ -60,11 +66,25 @@ static const uint8_t AS7341_READOUT[] PROGMEM = { N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 }; static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +// This chip has no FZ, FY or FXL band, so those slots are marked absent. +// clang-format off +static const uint16_t AS7341_WAVELENGTHS[] PROGMEM = { + 415, 445, ABSENT_NM, 480, 515, ABSENT_NM, 555, ABSENT_NM, 590, 630, 680, 910, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, the same for every band. +// The last two entries stay at zero because this chip has no 1024x or 2048x gain. +static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, + 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; +static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); const ChipInfo AS7341_CHIP PROGMEM = { .name = AS7341_NAME, .init = AS7341_INIT, .smux = AS7341_SMUX, .readout = AS7341_READOUT, + .wavelengths = AS7341_WAVELENGTHS, + .gain_correction = AS7341_GAIN_CORRECTION, .init_count = 1, .astep_reg = 0xCA, .cfg0_reg = 0xA9, @@ -73,11 +93,14 @@ const ChipInfo AS7341_CHIP PROGMEM = { .id_reg = 0x92, .id_mask = 0xFC, .id_value = 0x09 << 2, + .led_reg = 0x74, + .led_sel_reg = 0x70, // CONFIG, whose LED_SEL bit connects the pin to the driver .adc_slots = AS7341_ADC_SLOTS, .smux_steps = AS7341_SMUX_STEPS, .integration_cycles = 1, .smux_len = AS7341_SMUX_LEN, .auto_smux = false, + .gain_correction_per_band = false, .extra_clear_slot = NO_SLOT, }; #endif @@ -97,12 +120,41 @@ static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05 static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +// clang-format off +static const uint16_t AS7343_WAVELENGTHS[] PROGMEM = { + 405, 425, 450, 475, 515, 555, 550, 600, 640, 690, 745, 855, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7343_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, per band. +// clang-format off +static const float AS7343_GAIN_CORRECTION[] PROGMEM = { + 1.149f, 1.100f, 1.060f, 1.070f, 1.063f, 1.051f, 1.062f, 1.056f, 1.049f, 1.040f, 1.080f, 1.038f, 1.065f, // 0.5x + 1.090f, 1.128f, 1.064f, 1.071f, 1.063f, 1.050f, 1.068f, 1.055f, 1.047f, 1.039f, 1.075f, 1.038f, 1.085f, // 1x + 1.083f, 1.086f, 1.062f, 1.070f, 1.062f, 1.049f, 1.057f, 1.053f, 1.045f, 1.038f, 1.063f, 1.037f, 1.069f, // 2x + 1.059f, 1.068f, 1.056f, 1.066f, 1.058f, 1.046f, 1.051f, 1.051f, 1.044f, 1.036f, 1.059f, 1.035f, 1.053f, // 4x + 1.100f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.076f, 1.088f, // 8x + 1.099f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.075f, 1.087f, // 16x + 1.088f, 1.096f, 1.085f, 1.097f, 1.087f, 1.078f, 1.079f, 1.080f, 1.071f, 1.067f, 1.087f, 1.064f, 1.076f, // 32x + 1.083f, 1.091f, 1.078f, 1.090f, 1.079f, 1.072f, 1.072f, 1.073f, 1.064f, 1.062f, 1.080f, 1.057f, 1.069f, // 64x + 1.076f, 1.084f, 1.072f, 1.085f, 1.074f, 1.066f, 1.062f, 1.067f, 1.055f, 1.056f, 1.074f, 1.051f, 1.061f, // 128x + 1.067f, 1.074f, 1.063f, 1.075f, 1.064f, 1.059f, 1.055f, 1.058f, 1.049f, 1.051f, 1.064f, 1.044f, 1.053f, // 256x + 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, // 512x + 1.033303857f, 1.034763813f, 1.029855013f, 1.036911249f, 0.988150537f, 1.029003501f, 1.021363258f, + 0.98762089f, 1.021713376f, 1.040108204f, 0.987417698f, 1.019481421f, 0.987270057f, // 1024x + 0.986859143f, 0.980877221f, 0.9469769f, 0.985535324f, 0.926243961f, 0.959814787f, 1.007963896f, + 0.942685187f, 1.007630587f, 1.025460005f, 0.928696275f, 1.001296639f, 1.041275263f, // 2048x +}; +// clang-format on +static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); static constexpr ChipInfo as7343_chip(const char *name) { return { .name = name, .init = AS7343_INIT, .smux = AS7343_SMUX, .readout = AS7343_READOUT, + .wavelengths = AS7343_WAVELENGTHS, + .gain_correction = AS7343_GAIN_CORRECTION, .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), .astep_reg = 0xD4, .cfg0_reg = 0xBF, @@ -111,11 +163,14 @@ static constexpr ChipInfo as7343_chip(const char *name) { .id_reg = 0x5A, .id_mask = 0xFF, .id_value = 0x81, + .led_reg = 0xCD, + .led_sel_reg = 0, // the LED register drives the pin on its own .adc_slots = AS7343_ADC_SLOTS, .smux_steps = 1, .integration_cycles = 3, .smux_len = AS7343_SMUX_LEN, .auto_smux = true, + .gain_correction_per_band = true, .extra_clear_slot = 4, }; } @@ -133,6 +188,34 @@ bool AS734XComponent::select_bank_(uint8_t reg) { return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); } +bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { + uint8_t data = 0; + if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) { + return false; + } + data = value ? (data | mask) : (data & ~mask); + return this->write_byte(reg, data); +} + +// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime +// takes effect on the next reading rather than only after a restart. +bool AS734XComponent::write_exposure_() { + const ChipInfo *chip = this->chip_; + if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || + !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { + return false; + } + this->reading_gain_ = this->gain_; + this->reading_atime_ = this->atime_; + this->reading_astep_ = this->astep_; + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + return true; +} + void AS734XComponent::setup() { const ChipInfo *chip = this->chip_; const uint8_t id_reg = pgm(chip->id_reg); @@ -152,9 +235,7 @@ void AS734XComponent::setup() { ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); } // Everything from here on, including the measurement registers, is in the high bank. - ok = ok && this->select_bank_(REG_ATIME) && this->write_byte(REG_ATIME, this->atime_) && - this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) && - this->write_byte(pgm(chip->cfg1_reg), this->gain_); + ok = ok && this->write_exposure_(); if (pgm(chip->auto_smux)) { const uint8_t *smux = pgm(chip->smux); const uint8_t len = pgm(chip->smux_len); @@ -167,10 +248,6 @@ void AS734XComponent::setup() { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. - const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; - this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); - this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); this->disable_loop(); } @@ -181,24 +258,61 @@ void AS734XComponent::dump_config() { " Chip: %s\n" " Gain: %gx\n" " ATIME: %u\n" - " ASTEP: %u", - LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_); + " ASTEP: %u\n" + " Glass attenuation factor: %.3f", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_, this->glass_attenuation_factor_); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - for (auto *sens : this->sensors_) { - LOG_SENSOR(" ", "Counts", sens); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + char label[24]; + const char *name = LOG_STR_ARG(BandNames::get_log_str(band, BandNames::LAST_INDEX)); + snprintf(label, sizeof(label), "Counts %s", name); + sensor::log_sensor(TAG, " ", label, this->sensors_[band]); + snprintf(label, sizeof(label), "Basic counts %s", name); + sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); } + LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } +void AS734XComponent::enable_led(bool enable) { + const uint8_t led_reg = pgm(this->chip_->led_reg); + if (led_reg == 0) { + return; + } + // The LED register only drives the LED while LED_SEL is set, so the pin is handed over before + // switching on and released only after switching off. The other order would leave the LED lit + // with nothing able to turn it off. + const uint8_t led_sel_reg = pgm(this->chip_->led_sel_reg); + bool ok = true; + if (enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, true); + } + ok = this->update_bits_(led_reg, LED_ACT, enable) && ok; + if (!enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, false) && ok; + } + // The measurement path assumes the high bank, so leave the chip there for a reading in flight + ok = this->select_bank_(REG_ENABLE) && ok; + if (!ok) { + ESP_LOGW(TAG, "Failed to switch the LED"); + } +} + void AS734XComponent::update() { if (this->state_ != State::STATE_IDLE) { ESP_LOGW(TAG, "Skipping update, previous measurement still running"); return; } + if (!this->write_exposure_()) { + this->abort_measurement_(LOG_STR("Failed to apply measurement settings")); + return; + } this->step_ = 0; + this->peak_raw_count_ = 0; + this->raw_counts_.fill(0); this->enable_loop(); if (!this->start_step_()) { this->abort_measurement_(LOG_STR("Failed to start measurement")); @@ -270,14 +384,18 @@ void AS734XComponent::read_step_() { const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); for (uint8_t band = 0; band < NUM_BANDS; band++) { const uint8_t slot = progmem_read_byte(&readout[band]); - if (slot == NO_SLOT || this->sensors_[band] == nullptr) { + if (slot == NO_SLOT) { continue; } - uint32_t counts = slot_value(slot); + const uint16_t raw = slot_value(slot); + this->peak_raw_count_ = std::max(this->peak_raw_count_, raw); + uint16_t counts = raw; if (band == CLEAR_BAND && extra_clear != NO_SLOT) { - counts = (counts + slot_value(extra_clear)) / 2; + const uint16_t extra = slot_value(extra_clear); + this->peak_raw_count_ = std::max(this->peak_raw_count_, extra); + counts = (static_cast(raw) + extra) / 2; } - this->sensors_[band]->publish_state(counts); + this->raw_counts_[band] = counts; } if (++this->step_ < pgm(this->chip_->smux_steps)) { if (!this->start_step_()) { @@ -285,11 +403,104 @@ void AS734XComponent::read_step_() { } return; } + this->publish_measurement_(); this->status_clear_warning(); this->state_ = State::STATE_IDLE; this->disable_loop(); } +// A basic count is the raw count scaled to one unit of gain and one millisecond of integration +// time, which makes readings taken with different settings comparable. The manufacturer's +// conversion factors are calibrated against that definition. +void AS734XComponent::calculate_basic_counts_() { + this->basic_counts_.fill(0.0f); + this->max_basic_count_ = 0.0f; + this->max_band_basic_count_ = 0.0f; + this->clear_basic_count_ = 0.0f; + + // The gain enum counts in powers of two starting at 0.5x, so the multiplier is 2^gain / 2. + const float gain_x = static_cast(1 << this->reading_gain_) / 2.0f; + const float integration_ms = (1.0f + this->reading_atime_) * (1.0f + this->reading_astep_) * 2.78e-3f; + if (gain_x <= 0.0f || integration_ms <= 0.0f) { + return; + } + const float inv_exposure = 1.0f / (gain_x * integration_ms); + const ChipInfo *chip = this->chip_; + const uint16_t *wavelengths = pgm(chip->wavelengths); + const float *gain_correction = pgm(chip->gain_correction); + const bool per_band = pgm(chip->gain_correction_per_band); + const uint8_t correction_base = per_band ? this->reading_gain_ * NUM_BANDS : this->reading_gain_; + + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint16_t wavelength = pgm(wavelengths[band]); + if (wavelength == ABSENT_NM) { + continue; + } + float basic_count = this->raw_counts_[band] * inv_exposure; + basic_count -= this->dark_current_[band]; + basic_count = std::max(basic_count, 0.0f); + basic_count *= pgm(gain_correction[per_band ? correction_base + band : correction_base]); + basic_count *= this->channel_correction_[band]; + basic_count *= this->glass_attenuation_factor_; + + this->basic_counts_[band] = basic_count; + this->max_basic_count_ = std::max(this->max_basic_count_, basic_count); + if (wavelength == WIDEBAND_NM) { + this->clear_basic_count_ = basic_count; + } else if (wavelength >= VISIBLE_MIN_NM && wavelength <= VISIBLE_MAX_NM) { + this->max_band_basic_count_ = std::max(this->max_band_basic_count_, basic_count); + } + } +} + +float AS734XComponent::normalization_divisor_() const { + float divisor = 1.0f; + switch (this->normalization_) { + case Normalization::NORMALIZATION_ALL: + divisor = this->max_basic_count_; + break; + case Normalization::NORMALIZATION_BANDS: + divisor = this->max_band_basic_count_; + break; + case Normalization::NORMALIZATION_CLEAR: + divisor = this->clear_basic_count_; + break; + case Normalization::NORMALIZATION_NONE: + return 1.0f; + } + // In the dark the reference can be zero, and dividing by that would publish infinities. + return divisor > 0.0f ? divisor : 1.0f; +} + +void AS734XComponent::publish_basic_counts_() { + // Normalization describes the shape of the spectrum rather than its absolute level. Channels + // outside the chosen reference can exceed 1.0 and are left that way rather than clipped: with + // BANDS the near infrared channel legitimately sits well above the visible bands. + const float scale = 1.0f / this->normalization_divisor_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->basic_counts_sensors_[band] != nullptr) { + this->basic_counts_sensors_[band]->publish_state(this->basic_counts_[band] * scale); + } + } +} + +void AS734XComponent::publish_measurement_() { + this->calculate_basic_counts_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->sensors_[band] != nullptr) { + this->sensors_[band]->publish_state(this->raw_counts_[band]); + } + } + this->publish_basic_counts_(); + if (this->saturation_level_sensor_ != nullptr) { + const uint32_t max_adc = + std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); + const float level = max_adc == 0 ? 0.0f : 100.0f * this->peak_raw_count_ / max_adc; + ESP_LOGV(TAG, "Highest ADC count %u of %u (%.1f%%)", this->peak_raw_count_, static_cast(max_adc), level); + this->saturation_level_sensor_->publish_state(level); + } +} + void AS734XComponent::abort_measurement_(const LogString *reason) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); this->write_byte(REG_ENABLE, ENABLE_PON); diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h index 184084ca2b7..f2b68d380ec 100644 --- a/esphome/components/as734x/as734x.h +++ b/esphome/components/as734x/as734x.h @@ -26,7 +26,26 @@ enum Gain : uint8_t { GAIN_2048X, // AS7343 only }; -static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py +static constexpr uint8_t GAIN_COUNT = 13; // entries in Gain, for sizing the correction tables +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +// What the published basic counts are divided by. The clear channel is wideband, so it is roughly +// the size of all the bands together, and the near infrared channel outgrows them under daylight. +// Dividing by the largest of every channel therefore leaves the visible bands squeezed into the +// bottom of the range. +enum class Normalization : uint8_t { + NORMALIZATION_NONE, // publish absolute basic counts + NORMALIZATION_ALL, // largest of every channel + NORMALIZATION_BANDS, // largest of the visible bands only + NORMALIZATION_CLEAR, // the wideband clear channel +}; + +// Nominal centre wavelengths in nanometres bound the visible bands. The clear channel is wideband +// and has no single centre wavelength, so it is marked with zero instead. +static constexpr uint16_t VISIBLE_MIN_NM = 380; +static constexpr uint16_t VISIBLE_MAX_NM = 700; +static constexpr uint16_t WIDEBAND_NM = 0; +static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have struct RegValue { uint8_t reg; @@ -36,9 +55,11 @@ struct RegValue { /// Per-chip constants, stored in flash. The pointed-to tables are in flash too. struct ChipInfo { const char *name; - const RegValue *init; // written in order; the bank is selected per register address - const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images - const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band uint8_t init_count; uint8_t astep_reg; uint8_t cfg0_reg; @@ -47,12 +68,15 @@ struct ChipInfo { uint8_t id_reg; uint8_t id_mask; uint8_t id_value; - uint8_t adc_slots; // 16-bit data registers read after ASTATUS - uint8_t smux_steps; // measurements per update + uint8_t led_reg; // LED driver register (0: this chip has no LED control) + uint8_t led_sel_reg; // register whose LED_SEL bit hands the pin to the driver (0: not needed) + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update uint8_t integration_cycles; - uint8_t smux_len; // bytes per SMUX image - bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup - uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + bool gain_correction_per_band; // the correction table is indexed by gain and band, not gain alone + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) }; #ifdef USE_AS7341 @@ -65,7 +89,9 @@ extern const ChipInfo TCS3448_CHIP; class AS734XComponent : public PollingComponent, public i2c::I2CDevice { public: - explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) {} + // Channel correction is multiplicative, so it starts at unity rather than zero in case no + // calibration is configured. + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) { this->channel_correction_.fill(1.0f); } void setup() override; void dump_config() override; @@ -75,21 +101,58 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void set_gain(Gain gain) { this->gain_ = gain; } void set_atime(uint8_t atime) { this->atime_ = atime; } void set_astep(uint16_t astep) { this->astep_ = astep; } + + /// Turns the sensor's LED driver on or off. Meant for lambdas. + void enable_led(bool enable); + + // Calibration applied while turning raw counts into basic counts. + void set_dark_current(const std::array &values) { this->dark_current_ = values; } + void set_channel_correction(const std::array &values) { this->channel_correction_ = values; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + /// Chooses what the published basic counts are divided by. + void set_basic_counts_normalization(Normalization normalization) { this->normalization_ = normalization; } + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } + + SUB_SENSOR(saturation_level) protected: bool select_bank_(uint8_t reg); + bool update_bits_(uint8_t reg, uint8_t mask, bool value); + bool write_exposure_(); bool start_step_(); void read_step_(); void abort_measurement_(const LogString *reason); + void calculate_basic_counts_(); + void publish_measurement_(); + void publish_basic_counts_(); + float normalization_divisor_() const; std::array sensors_{}; + std::array basic_counts_sensors_{}; + std::array dark_current_{}; + std::array channel_correction_; + std::array basic_counts_{}; + std::array raw_counts_{}; const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy uint32_t start_ms_{0}; uint32_t integration_ms_{0}; uint32_t timeout_ms_{0}; + float glass_attenuation_factor_{1.0f}; + float max_basic_count_{0.0f}; // largest of every channel + float max_band_basic_count_{0.0f}; // largest of the visible bands only + float clear_basic_count_{0.0f}; // the wideband clear channel uint16_t astep_{0}; + uint16_t peak_raw_count_{0}; // highest ADC value of this measurement, before the clear channels are averaged + // What the running measurement was started with, so the maths matches the data even if the + // configuration is changed while a measurement is in flight. + uint16_t reading_astep_{0}; + Gain reading_gain_{GAIN_1X}; + uint8_t reading_atime_{0}; enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Normalization normalization_{Normalization::NORMALIZATION_NONE}; Gain gain_{GAIN_1X}; uint8_t atime_{0}; uint8_t step_{0}; diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py index aa1487f8a97..726d15d6fff 100644 --- a/esphome/components/as734x/sensor.py +++ b/esphome/components/as734x/sensor.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv -from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT +from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT import esphome.final_validate as fv from esphome.types import ConfigType @@ -10,7 +10,11 @@ from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent DEPENDENCIES = [DOMAIN] +CONF_BASIC_COUNTS = "basic_counts" +CONF_SATURATION_LEVEL = "saturation_level" + ICON_COUNTS = "mdi:counter" +ICON_SATURATION = "mdi:weather-sunny-alert" _COUNTS_SENSOR = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, @@ -19,12 +23,31 @@ _COUNTS_SENSOR = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) +# Basic counts are fractional, so they need decimals where raw counts do not. +_BASIC_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=6, + state_class=STATE_CLASS_MEASUREMENT, +) + +_BAND_KEYS = (CONF_COUNTS, CONF_BASIC_COUNTS) + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), cv.Optional(CONF_COUNTS, default={}): cv.Schema( {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} ), + cv.Optional(CONF_BASIC_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _BASIC_COUNTS_SENSOR for band in BANDS_43} + ), + cv.Optional(CONF_SATURATION_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_SATURATION, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ) @@ -32,12 +55,13 @@ CONFIG_SCHEMA = cv.Schema( def _final_validate(config: ConfigType) -> ConfigType: full = fv.full_config.get() hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) - if hub[CONF_TYPE] == "AS7341" and ( - extra := [band for band in config[CONF_COUNTS] if band not in BANDS_41] - ): - raise cv.Invalid( - f"The AS7341 has no {', '.join(extra)} band", path=[CONF_COUNTS, extra[0]] - ) + if hub[CONF_TYPE] != "AS7341": + return config + for key in _BAND_KEYS: + if extra := [band for band in config.get(key, {}) if band not in BANDS_41]: + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[key, extra[0]] + ) return config @@ -47,7 +71,14 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS734X_ID]) counts = config[CONF_COUNTS] + basic_counts = config[CONF_BASIC_COUNTS] for i, band in enumerate(BANDS_43): if (conf := counts.get(band)) is not None: sens = await sensor.new_sensor(conf) cg.add(hub.set_counts_sensor(sens, i)) + if (conf := basic_counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_basic_counts_sensor(sens, i)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py index 98ae9571693..d668523c816 100644 --- a/tests/component_tests/as734x/test_as734x.py +++ b/tests/component_tests/as734x/test_as734x.py @@ -92,3 +92,31 @@ def test_exposure_bound( return with pytest.raises(cv.Invalid, match=error): HUB_SCHEMA(config) + + +def test_as7343_basic_counts_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in ("f1", "fz", "clear"): + assert ( + f"hub43->set_basic_counts_sensor(b43_{band}, {BAND_INDEX[band]});" + in main_cpp + ) + + +def test_as7341_calibration_spreads_over_the_band_slots(generate_main) -> None: + """The AS7341 has ten bands, but the C++ side indexes all thirteen slots. + + The three bands it does not have keep the neutral value, so a correction or dark + current given for F3 reaches slot 3 rather than slot 2. + """ + main_cpp = generate_main(CONFIG) + + assert ( + "hub41->set_channel_correction({1.1f, 1.2f, 1.0f, 1.3f, 1.4f, 1.0f, 1.5f, " + "1.0f, 1.6f, 1.7f, 1.8f, 1.9f, 2.0f});" in main_cpp + ) + assert ( + "hub41->set_dark_current({0.1f, 0.2f, 0.0f, 0.3f, 0.4f, 0.0f, 0.5f, " + "0.0f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f});" in main_cpp + ) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml index 0ae5ce2ce10..da014d32e6a 100644 --- a/tests/component_tests/as734x/test_as734x.yaml +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -11,6 +11,10 @@ i2c: as734x: - id: hub41 type: AS7341 + normalize_basic_counts: ALL + calibration: + channel_correction: [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] + dark_current: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - id: hub43 type: AS7343 address: 0x3A @@ -45,3 +49,8 @@ sensor: f8: {id: s43_f8, name: s43 f8} nir: {id: s43_nir, name: s43 nir} clear: {id: s43_clear, name: s43 clear} + basic_counts: + f1: {id: b43_f1, name: b43 f1} + fz: {id: b43_fz, name: b43 fz} + clear: {id: b43_clear, name: b43 clear} + saturation_level: {id: sat43, name: sat 43} diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml index 5fa94d7ef5b..0ad83bac4dc 100644 --- a/tests/components/as734x/common.yaml +++ b/tests/components/as734x/common.yaml @@ -6,15 +6,24 @@ as734x: gain: X8 atime: 120 astep: 99 + normalize_basic_counts: BANDS + calibration: + channel_correction: [1.0, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09] + dark_current: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + glass_attenuation_factor: 1.2 - id: as7343_hub type: AS7343 i2c_id: i2c_bus address: 0x3A gain: X512 + normalize_basic_counts: CLEAR - id: tcs3448_hub type: TCS3448 i2c_id: i2c_bus gain: X2048 + calibration: + dark_current: + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] sensor: - platform: as734x @@ -26,6 +35,13 @@ sensor: name: AS7341 NIR clear: name: AS7341 Clear + basic_counts: + f1: + name: AS7341 F1 basic + clear: + name: AS7341 Clear basic + saturation_level: + name: AS7341 Saturation - platform: as734x as734x_id: as7343_hub counts: @@ -35,6 +51,13 @@ sensor: name: AS7343 FXL clear: name: AS7343 Clear + basic_counts: + fz: + name: AS7343 FZ basic + fy: + name: AS7343 FY basic + saturation_level: + name: AS7343 Saturation - platform: as734x as734x_id: tcs3448_hub counts: