[as734x] [2/3] Add basic counts, calibration and saturation level (#18011)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Anton Viktorov
2026-10-15 10:48:18 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 27d8d49b17
commit a58ce2d641
7 changed files with 486 additions and 37 deletions
+89 -5
View File
@@ -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]))
+226 -15
View File
@@ -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 <algorithm>
@@ -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<uint32_t>(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<float>(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<uint32_t>((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<unsigned>(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);
+73 -10
View File
@@ -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<float, NUM_BANDS> &values) { this->dark_current_ = values; }
void set_channel_correction(const std::array<float, NUM_BANDS> &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<sensor::Sensor *, NUM_BANDS> sensors_{};
std::array<sensor::Sensor *, NUM_BANDS> basic_counts_sensors_{};
std::array<float, NUM_BANDS> dark_current_{};
std::array<float, NUM_BANDS> channel_correction_;
std::array<float, NUM_BANDS> basic_counts_{};
std::array<uint16_t, NUM_BANDS> 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};
+38 -7
View File
@@ -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)
@@ -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
)
@@ -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}
+23
View File
@@ -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: