mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 04:02:54 +00:00
[mics_5524] Add MiCS-5524 gas sensor platform (1/2) (#19721)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
pre-commit-ci-lite[bot]
J. Nick Koston
parent
38db8c1554
commit
9440012413
@@ -391,6 +391,7 @@ esphome/components/micro_wake_word/* @jesserockz @kahrendt
|
||||
esphome/components/micronova/* @edenhaus @jorre05
|
||||
esphome/components/microphone/* @jesserockz @kahrendt
|
||||
esphome/components/mics_4514/* @jesserockz
|
||||
esphome/components/mics_5524/* @nliaudat
|
||||
esphome/components/midea/* @dudanov
|
||||
esphome/components/midea_ir/* @dudanov
|
||||
esphome/components/midea_portasplit/* @Fexiven
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
|
||||
CODEOWNERS = ["@nliaudat"]
|
||||
DOMAIN = "mics_5524"
|
||||
|
||||
mics_5524_ns = cg.esphome_ns.namespace("mics_5524")
|
||||
|
||||
MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent)
|
||||
MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor)
|
||||
|
||||
CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier"
|
||||
CONF_VCC = "vcc"
|
||||
CONF_AIR_REFERENCE = "air_reference"
|
||||
CONF_SAMPLE_COUNT = "sample_count"
|
||||
CONF_SAMPLE_INTERVAL = "sample_interval"
|
||||
CONF_MAX_PPM = "max_ppm"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from esphome.const import (
|
||||
CONF_AMMONIA,
|
||||
CONF_CARBON_MONOXIDE,
|
||||
CONF_ETHANOL,
|
||||
CONF_HYDROGEN,
|
||||
CONF_METHANE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VendorCurve:
|
||||
"""Constants of the vendor model for one gas."""
|
||||
|
||||
label: str # vendor name of the gas
|
||||
threshold: float # ratio at or below which the gas counts as detected
|
||||
gain: float # ppm per unit ratio
|
||||
min_ppm: float # the vendor reports 0 ppm below this
|
||||
max_ppm: float # upper end of the measuring range
|
||||
|
||||
|
||||
# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges.
|
||||
GASES: dict[str, VendorCurve] = {
|
||||
CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0),
|
||||
CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0),
|
||||
CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0),
|
||||
CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0),
|
||||
CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0),
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "mics_5524.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mics_5524 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "mics_5524");
|
||||
|
||||
void MiCS5524GasSensor::publish_ratio(float ratio) {
|
||||
const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_);
|
||||
ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm);
|
||||
this->publish_state(ppm);
|
||||
}
|
||||
|
||||
void MiCS5524Component::setup() {
|
||||
if (this->has_reference_()) {
|
||||
ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_);
|
||||
}
|
||||
|
||||
if (this->warmup_time_ > 0) {
|
||||
this->stop_poller();
|
||||
this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); });
|
||||
ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000);
|
||||
}
|
||||
|
||||
if (this->calibration_samples_ > 0) {
|
||||
// Nothing is stored across reboots; the warm-up window starts here, so the delay covers it.
|
||||
const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_);
|
||||
this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); });
|
||||
ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000);
|
||||
} else if (!this->has_reference_()) {
|
||||
ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n"
|
||||
" set 'air_reference:' or enable 'calibration:'");
|
||||
}
|
||||
|
||||
// The loop only samples while a clean-air calibration runs; `update()` publishes.
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void MiCS5524Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"MiCS-5524:\n"
|
||||
" VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n"
|
||||
" Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms",
|
||||
this->vcc_, this->voltage_multiplier_, static_cast<unsigned>(this->samples_), this->warmup_time_ / 1000,
|
||||
this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"),
|
||||
this->sample_interval_);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
if (this->has_reference_()) {
|
||||
ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Air reference: not available yet");
|
||||
}
|
||||
#ifdef MICS_5524_GAS_COUNT
|
||||
for (auto *gas : this->gases_) {
|
||||
LOG_SENSOR(" ", "Gas", gas);
|
||||
ESP_LOGCONFIG(TAG, " Range: %.1f - %.1f ppm", gas->get_min_ppm(), gas->get_max_ppm());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
float MiCS5524Component::sample_voltage_() {
|
||||
// Taken back to back; waiting between them would block the main loop.
|
||||
float sum = 0.0f;
|
||||
for (uint8_t i = 0; i < this->samples_; i++)
|
||||
sum += this->source_->sample();
|
||||
return (sum / static_cast<float>(this->samples_)) * this->voltage_multiplier_;
|
||||
}
|
||||
|
||||
void MiCS5524Component::publish_ratio_(float ratio) {
|
||||
#ifdef MICS_5524_GAS_COUNT
|
||||
for (auto *gas : this->gases_) {
|
||||
gas->publish_ratio(ratio);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
float MiCS5524Component::read_air_value_() {
|
||||
const float voltage = this->sample_voltage_();
|
||||
const float x = micsmath::air_value_from_voltage(voltage, this->vcc_);
|
||||
if (std::isfinite(x))
|
||||
return x;
|
||||
// The status flag makes this log once per fault, not once per update.
|
||||
if (!this->status_has_warning()) {
|
||||
ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'",
|
||||
voltage, this->vcc_);
|
||||
}
|
||||
this->status_set_warning(LOG_STR("analog output out of range"));
|
||||
return NAN;
|
||||
}
|
||||
|
||||
void MiCS5524Component::update() {
|
||||
// Every gas starts unknown and the reference is never cleared, so there is nothing to republish.
|
||||
if (!this->has_reference_())
|
||||
return;
|
||||
|
||||
const float air_value = this->read_air_value_();
|
||||
if (std::isnan(air_value)) {
|
||||
this->publish_ratio_(NAN);
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
|
||||
const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_);
|
||||
ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio);
|
||||
this->publish_ratio_(ratio);
|
||||
}
|
||||
|
||||
void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) {
|
||||
this->calibration_delay_ = delay;
|
||||
this->calibration_samples_ = samples;
|
||||
this->sample_interval_ = sample_interval;
|
||||
}
|
||||
|
||||
void MiCS5524Component::loop() {
|
||||
if (!this->calibrating_) {
|
||||
// Only a calibration samples here; the deferred start is a timer.
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->calibration_last_sample_ < this->sample_interval_)
|
||||
return;
|
||||
|
||||
this->calibration_last_sample_ = now;
|
||||
this->process_calibration_();
|
||||
}
|
||||
|
||||
void MiCS5524Component::begin_calibration_() {
|
||||
this->calibrating_ = true;
|
||||
this->calibration_last_sample_ = App.get_loop_component_start_time();
|
||||
this->calibration_count_ = 0;
|
||||
this->calibration_attempts_ = 0;
|
||||
this->calibration_sum_ = 0.0f;
|
||||
this->calibration_min_ = INFINITY;
|
||||
this->calibration_max_ = -INFINITY;
|
||||
this->enable_loop();
|
||||
|
||||
ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air",
|
||||
this->calibration_samples_);
|
||||
}
|
||||
|
||||
void MiCS5524Component::process_calibration_() {
|
||||
this->calibration_attempts_++;
|
||||
|
||||
const float reference = this->read_air_value_();
|
||||
if (!std::isnan(reference)) {
|
||||
this->calibration_min_ = std::min(this->calibration_min_, reference);
|
||||
this->calibration_max_ = std::max(this->calibration_max_, reference);
|
||||
this->calibration_sum_ += reference;
|
||||
this->calibration_count_++;
|
||||
}
|
||||
|
||||
if (this->calibration_attempts_ >= this->calibration_samples_)
|
||||
this->finish_calibration_();
|
||||
}
|
||||
|
||||
void MiCS5524Component::finish_calibration_() {
|
||||
this->calibrating_ = false;
|
||||
this->disable_loop();
|
||||
|
||||
const uint32_t attempts = this->calibration_attempts_;
|
||||
const uint32_t valid = this->calibration_count_;
|
||||
// A majority of the attempts has to have produced a sample, two at least.
|
||||
const uint32_t required = std::max<uint32_t>((attempts + 1) / 2, 2);
|
||||
|
||||
if (valid < required) {
|
||||
ESP_LOGW(TAG, "calibration failed, %" PRIu32 " of %" PRIu32 " samples were valid", valid, attempts);
|
||||
this->schedule_calibration_retry_();
|
||||
return;
|
||||
}
|
||||
|
||||
const float reference = this->calibration_sum_ / static_cast<float>(valid);
|
||||
|
||||
// A sensor still settling, or not in clean air, varies too much to be the reference.
|
||||
if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) {
|
||||
ESP_LOGW(
|
||||
TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air",
|
||||
this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_);
|
||||
this->schedule_calibration_retry_();
|
||||
return;
|
||||
}
|
||||
|
||||
this->reference_ = reference;
|
||||
this->status_clear_warning();
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n"
|
||||
" hard-code 'air_reference: %.4f' to skip the calibration at boot",
|
||||
this->reference_, valid, attempts, this->reference_);
|
||||
}
|
||||
|
||||
void MiCS5524Component::schedule_calibration_retry_() {
|
||||
this->status_set_warning();
|
||||
// A zero delay would log the same failure about five times per second.
|
||||
const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS);
|
||||
this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); });
|
||||
ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000);
|
||||
}
|
||||
|
||||
} // namespace esphome::mics_5524
|
||||
@@ -0,0 +1,104 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/voltage_sampler/voltage_sampler.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "mics_math.h"
|
||||
|
||||
namespace esphome::mics_5524 {
|
||||
|
||||
/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas.
|
||||
class MiCS5524GasSensor : public sensor::Sensor {
|
||||
public:
|
||||
/// The vendor constants from coefficients.py.
|
||||
MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm)
|
||||
: curve_{threshold, gain, min_ppm, max_ppm} {}
|
||||
|
||||
/// Publish the concentration for `ratio`; NAN publishes `unknown`.
|
||||
void publish_ratio(float ratio);
|
||||
|
||||
float get_min_ppm() const { return this->curve_.min_ppm; }
|
||||
float get_max_ppm() const { return this->curve_.max_ppm; }
|
||||
|
||||
protected:
|
||||
micsmath::VendorCurve curve_;
|
||||
};
|
||||
|
||||
/// The MiCS-5524 breakout: one analog output, converted for every configured gas against
|
||||
/// the clean-air reference x_air from `air_reference:` or a calibration at boot.
|
||||
class MiCS5524Component : public PollingComponent {
|
||||
public:
|
||||
/// `source` must outlive this component, which code generation guarantees.
|
||||
explicit MiCS5524Component(voltage_sampler::VoltageSampler *source) : source_(source) {}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
#ifdef MICS_5524_GAS_COUNT
|
||||
void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); }
|
||||
#endif
|
||||
|
||||
void set_vcc(float vcc) { this->vcc_ = vcc; }
|
||||
void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; }
|
||||
void set_samples(uint8_t samples) { this->samples_ = samples; }
|
||||
void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; }
|
||||
/// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart.
|
||||
void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval);
|
||||
void set_air_reference(float air_reference) { this->reference_ = air_reference; }
|
||||
|
||||
protected:
|
||||
/// Averaged, scaled output voltage (V); the conversions run back to back.
|
||||
float sample_voltage_();
|
||||
/// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable.
|
||||
float read_air_value_();
|
||||
void publish_ratio_(float ratio);
|
||||
|
||||
void begin_calibration_();
|
||||
void process_calibration_();
|
||||
void finish_calibration_();
|
||||
void schedule_calibration_retry_();
|
||||
bool has_reference_() const { return this->reference_ > 0.0f; }
|
||||
|
||||
voltage_sampler::VoltageSampler *source_{nullptr};
|
||||
float vcc_{5.0f};
|
||||
float voltage_multiplier_{1.0f};
|
||||
uint32_t warmup_time_{0};
|
||||
|
||||
uint32_t calibration_delay_{0};
|
||||
/// 0 when calibration is disabled.
|
||||
uint32_t calibration_samples_{0};
|
||||
/// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor.
|
||||
uint32_t sample_interval_{20};
|
||||
|
||||
static constexpr uint32_t CALIBRATION_START_ID = 0;
|
||||
/// Floor of the delay between two calibration attempts (ms).
|
||||
static constexpr uint32_t CALIBRATION_RETRY_MIN_MS = 10000;
|
||||
|
||||
uint32_t calibration_last_sample_{0};
|
||||
uint32_t calibration_count_{0};
|
||||
uint32_t calibration_attempts_{0};
|
||||
float calibration_sum_{0.0f};
|
||||
/// Smallest and largest clean-air sample of the run, see calibration_is_stable().
|
||||
float calibration_min_{0.0f};
|
||||
float calibration_max_{0.0f};
|
||||
|
||||
/// Clean-air reference x_air in volts, 0 while there is none.
|
||||
float reference_{0.0f};
|
||||
|
||||
// Sized by code generation; the host unit tests run none, so the list compiles out there.
|
||||
#ifdef MICS_5524_GAS_COUNT
|
||||
StaticVector<MiCS5524GasSensor *, MICS_5524_GAS_COUNT> gases_;
|
||||
#endif
|
||||
|
||||
uint8_t samples_{4};
|
||||
bool calibrating_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::mics_5524
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py.
|
||||
// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain.
|
||||
// No ESPHome dependencies, so the host tests cover it.
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::mics_5524::micsmath {
|
||||
|
||||
/// Per-gas constants of the vendor model.
|
||||
struct VendorCurve {
|
||||
float threshold; ///< ratio at or below which the gas counts as detected
|
||||
float gain; ///< ppm per unit ratio
|
||||
float min_ppm; ///< the vendor reports 0 ppm below this
|
||||
float max_ppm; ///< upper end of the measuring range
|
||||
};
|
||||
|
||||
/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a
|
||||
/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces.
|
||||
inline float air_value_from_voltage(float voltage, float vcc) {
|
||||
if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc)
|
||||
return NAN;
|
||||
return vcc - voltage;
|
||||
}
|
||||
|
||||
/// Largest relative spread (max - min) / mean a clean-air calibration accepts.
|
||||
inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f;
|
||||
|
||||
/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean.
|
||||
inline bool calibration_is_stable(float min_value, float max_value, float mean) {
|
||||
if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f)
|
||||
return false;
|
||||
return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean;
|
||||
}
|
||||
|
||||
/// ratio = x / x_air (NaN when either value is unusable).
|
||||
inline float ratio_from_air_value(float x, float x_air) {
|
||||
if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f)
|
||||
return NAN;
|
||||
return x / x_air;
|
||||
}
|
||||
|
||||
/// ratio -> ppm as DFRobot_MICS getGasData(): 0 above the threshold or below the vendor
|
||||
/// range, the top of the range at ratio 0 (a saturated sensor), NaN stays NaN.
|
||||
inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) {
|
||||
if (!std::isfinite(ratio))
|
||||
return NAN;
|
||||
if (ratio > curve.threshold)
|
||||
return 0.0f;
|
||||
|
||||
const float ppm = (curve.threshold - ratio) / curve.gain;
|
||||
if (ppm < curve.min_ppm)
|
||||
return 0.0f;
|
||||
if (ppm > curve.max_ppm)
|
||||
return curve.max_ppm;
|
||||
return ppm;
|
||||
}
|
||||
|
||||
} // namespace esphome::mics_5524::micsmath
|
||||
@@ -0,0 +1,153 @@
|
||||
"""MiCS-5524 sensor platform: one entry per module, one sub-sensor per configured gas."""
|
||||
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, voltage_sampler
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CALIBRATION,
|
||||
CONF_DELAY,
|
||||
CONF_ID,
|
||||
CONF_VOLTAGE,
|
||||
CONF_WARMUP_TIME,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
CONF_AIR_REFERENCE,
|
||||
CONF_MAX_PPM,
|
||||
CONF_SAMPLE_COUNT,
|
||||
CONF_SAMPLE_INTERVAL,
|
||||
CONF_VCC,
|
||||
CONF_VOLTAGE_MULTIPLIER,
|
||||
MiCS5524Component,
|
||||
MiCS5524GasSensor,
|
||||
)
|
||||
from .coefficients import GASES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTO_LOAD = ["sensor", "voltage_sampler"]
|
||||
|
||||
# The main loop runs every ~16 ms, so a shorter spacing cannot sample more often.
|
||||
MIN_SAMPLE_INTERVAL_MS = 16
|
||||
|
||||
# Conversions per reading run back to back inside one loop pass.
|
||||
MAX_SAMPLE_COUNT = 16
|
||||
|
||||
_request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT")
|
||||
|
||||
|
||||
def _gas_schema(gas_key: str) -> cv.Schema:
|
||||
"""Schema of one gas sub-sensor."""
|
||||
curve = GASES[gas_key]
|
||||
return sensor.sensor_schema(
|
||||
MiCS5524GasSensor,
|
||||
unit_of_measurement=UNIT_PARTS_PER_MILLION,
|
||||
accuracy_decimals=1,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
).extend(
|
||||
{
|
||||
# A ceiling above the vendor range could never be reached.
|
||||
cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range(
|
||||
min=0, min_included=False, max=curve.max_ppm
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
CALIBRATION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_SAMPLE_COUNT, default=10): cv.int_range(
|
||||
min=2, max=MAX_SAMPLE_COUNT
|
||||
),
|
||||
cv.Optional(CONF_SAMPLE_INTERVAL, default="20ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_config(config: ConfigType) -> ConfigType:
|
||||
"""Reject an air reference at or above the supply; warn when there is none."""
|
||||
if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None:
|
||||
# The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever.
|
||||
if air_reference >= config[CONF_VCC]:
|
||||
raise cv.Invalid(
|
||||
f"air_reference ({air_reference:g} V) must be below vcc "
|
||||
f"({config[CONF_VCC]:g} V): it is how far the analog output sits "
|
||||
"below the supply in clean air"
|
||||
)
|
||||
elif CONF_CALIBRATION not in config:
|
||||
_LOGGER.warning(
|
||||
"no air reference configured - set 'air_reference:' or 'calibration:', "
|
||||
"the PPM values stay unknown until then"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MiCS5524Component),
|
||||
cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler),
|
||||
cv.Optional(
|
||||
CONF_VOLTAGE_MULTIPLIER, default=1.0
|
||||
): cv.positive_not_null_float,
|
||||
cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float,
|
||||
cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float,
|
||||
cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range(
|
||||
min=1, max=MAX_SAMPLE_COUNT
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_WARMUP_TIME, default="3min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA,
|
||||
**{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES},
|
||||
}
|
||||
).extend(cv.polling_component_schema("30s")),
|
||||
cv.has_at_least_one_key(*GASES),
|
||||
cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION),
|
||||
_validate_config,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE]))
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_vcc(config[CONF_VCC]))
|
||||
cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER]))
|
||||
cg.add(var.set_samples(config[CONF_SAMPLE_COUNT]))
|
||||
cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME]))
|
||||
|
||||
if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None:
|
||||
cg.add(var.set_air_reference(air_reference))
|
||||
elif (calibration := config.get(CONF_CALIBRATION)) is not None:
|
||||
cg.add(
|
||||
var.set_calibration(
|
||||
calibration[CONF_DELAY],
|
||||
calibration[CONF_SAMPLE_COUNT],
|
||||
calibration[CONF_SAMPLE_INTERVAL],
|
||||
)
|
||||
)
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
for gas_key, curve in GASES.items():
|
||||
if (gas_config := config.get(gas_key)) is None:
|
||||
continue
|
||||
_request_gas_slot(str(var))
|
||||
await sensors(
|
||||
gas_key,
|
||||
var.register_gas_sensor,
|
||||
curve.threshold,
|
||||
curve.gain,
|
||||
curve.min_ppm,
|
||||
gas_config[CONF_MAX_PPM],
|
||||
)
|
||||
@@ -165,6 +165,7 @@
|
||||
#define MDNS_DYNAMIC_TXT_COUNT 2
|
||||
#define LD2450_POLYGON_ZONE_COUNT 2
|
||||
#define MICRONOVA_LISTENER_COUNT 1
|
||||
#define MICS_5524_GAS_COUNT 5
|
||||
#define USE_MICRONOVA_WRITER
|
||||
#define MK2PVROUTER_LISTENER_COUNT 1
|
||||
#define NFC_TAG_LISTENER_COUNT 1
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
- platform: adc
|
||||
id: mics_5524_adc
|
||||
name: MiCS-5524 AO
|
||||
pin: GPIO34
|
||||
|
||||
# Calibrated at boot; every gas below it is published from the same ratio.
|
||||
- platform: mics_5524
|
||||
id: mics_5524_boot
|
||||
voltage: mics_5524_adc
|
||||
voltage_multiplier: 1.5
|
||||
sample_count: 3
|
||||
calibration:
|
||||
delay: 5s
|
||||
sample_count: 3
|
||||
carbon_monoxide:
|
||||
id: mics_5524_co
|
||||
name: MiCS-5524 CO
|
||||
max_ppm: 500.0
|
||||
methane:
|
||||
id: mics_5524_ch4
|
||||
name: MiCS-5524 CH4
|
||||
|
||||
# The reference is hard-coded, so nothing is calibrated at boot.
|
||||
- platform: mics_5524
|
||||
id: mics_5524_fixed
|
||||
voltage: mics_5524_adc
|
||||
air_reference: 3.6
|
||||
hydrogen:
|
||||
id: mics_5524_h2
|
||||
name: MiCS-5524 H2
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for the MiCS-5524 gas sensor code generation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.mics_5524.coefficients import GASES, VendorCurve
|
||||
from esphome.components.mics_5524.sensor import CONFIG_SCHEMA
|
||||
from esphome.const import (
|
||||
CONF_AMMONIA,
|
||||
CONF_CARBON_MONOXIDE,
|
||||
CONF_ETHANOL,
|
||||
CONF_HYDROGEN,
|
||||
CONF_METHANE,
|
||||
)
|
||||
|
||||
|
||||
def test_voltage_source_is_a_constructor_argument(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Every reading needs the sampler, so it is passed at construction."""
|
||||
main_cpp = generate_main(component_config_path("mics_5524.yaml"))
|
||||
|
||||
assert (
|
||||
"new(mics_5524_boot) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp
|
||||
)
|
||||
assert (
|
||||
"new(mics_5524_fixed) mics_5524::MiCS5524Component(mics_5524_adc);" in main_cpp
|
||||
)
|
||||
assert "set_source(" not in main_cpp
|
||||
|
||||
|
||||
def test_every_gas_becomes_a_sub_sensor_of_its_hub(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The curve is passed to the constructor and the sub-sensor to its own hub."""
|
||||
main_cpp = generate_main(component_config_path("mics_5524.yaml"))
|
||||
|
||||
# Threshold, gain, minimum and maximum ppm of the gas.
|
||||
assert (
|
||||
"new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, "
|
||||
"0.000405f, 1.0f, 500.0f);" in main_cpp
|
||||
)
|
||||
assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp
|
||||
# A gas without `max_ppm:` keeps the measuring range of the vendor.
|
||||
assert (
|
||||
"new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, "
|
||||
"2.3e-05f, 1000.0f, 25000.0f);" in main_cpp
|
||||
)
|
||||
assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp
|
||||
# The gas belongs to the sensor it is configured under, not to the first one.
|
||||
assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp
|
||||
assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp
|
||||
|
||||
|
||||
def test_shared_options_reach_the_hub(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The analog front end is configured once per physical sensor."""
|
||||
main_cpp = generate_main(component_config_path("mics_5524.yaml"))
|
||||
|
||||
assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp
|
||||
assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp
|
||||
assert "mics_5524_boot->set_samples(3);" in main_cpp
|
||||
assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp
|
||||
assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp
|
||||
assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp
|
||||
# `air_reference:` and `calibration:` exclude each other, so the sensor with the
|
||||
# hard-coded reference is never calibrated at boot.
|
||||
assert "mics_5524_fixed->set_calibration(" not in main_cpp
|
||||
|
||||
|
||||
def test_air_reference_and_calibration_exclude_each_other() -> None:
|
||||
"""Two references for one sensor would silently pick one, so it is an error."""
|
||||
with pytest.raises(cv.Invalid, match="Cannot specify more than one of"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"hydrogen": {"name": "MiCS-5524 H2 (reference)"},
|
||||
"air_reference": 3.6,
|
||||
"calibration": {"delay": "5s"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("air_reference", [5.0, 6.0])
|
||||
def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None:
|
||||
"""A reference at or above the supply would report every gas as 0 ppm forever."""
|
||||
with pytest.raises(cv.Invalid, match="must be below vcc"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"vcc": 5.0,
|
||||
"hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"},
|
||||
"air_reference": air_reference,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_air_reference_below_vcc_is_accepted() -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"vcc": 5.0,
|
||||
"hydrogen": {"name": "MiCS-5524 H2 (reference)"},
|
||||
"air_reference": 3.6,
|
||||
}
|
||||
)
|
||||
|
||||
assert config["air_reference"] == 3.6
|
||||
|
||||
|
||||
def test_at_least_one_gas_is_required() -> None:
|
||||
"""A sensor without a gas has nothing to publish."""
|
||||
with pytest.raises(cv.Invalid, match="Must contain at least one of"):
|
||||
CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0})
|
||||
|
||||
|
||||
def test_max_ppm_above_the_vendor_range_is_rejected() -> None:
|
||||
"""A ceiling the conversion can never reach is a configuration error."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at most 1000.0"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"carbon_monoxide": {
|
||||
"name": "MiCS-5524 CO (rejected)",
|
||||
"max_ppm": 2000.0,
|
||||
},
|
||||
"air_reference": 3.6,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_max_ppm_at_the_vendor_range_is_accepted() -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0},
|
||||
"air_reference": 3.6,
|
||||
}
|
||||
)
|
||||
|
||||
assert config["carbon_monoxide"]["max_ppm"] == 1000.0
|
||||
|
||||
|
||||
def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"carbon_monoxide": {"name": "MiCS-5524 CO (default)"},
|
||||
"air_reference": 3.6,
|
||||
}
|
||||
)
|
||||
|
||||
assert config["carbon_monoxide"]["max_ppm"] == 1000.0
|
||||
|
||||
|
||||
def test_calibration_cannot_sample_faster_than_the_loop() -> None:
|
||||
"""The main loop runs every ~16 ms, so a shorter spacing samples nothing extra."""
|
||||
with pytest.raises(cv.Invalid, match="value must be at least 16ms"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"hydrogen": {"name": "MiCS-5524 H2 (tick)"},
|
||||
"calibration": {"sample_interval": "10ms"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_voltage_multiplier_defaults_to_one() -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "mics_5524",
|
||||
"voltage": "mics_5524_adc",
|
||||
"hydrogen": {"name": "MiCS-5524 H2 (default)"},
|
||||
"air_reference": 3.6,
|
||||
}
|
||||
)
|
||||
|
||||
assert config["voltage_multiplier"] == 1.0
|
||||
|
||||
|
||||
def test_vendor_table_is_the_datasheet_one() -> None:
|
||||
"""The whole table is pinned, not only the gases a config file happens to use."""
|
||||
expected = {
|
||||
CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0),
|
||||
CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0),
|
||||
CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0),
|
||||
CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0),
|
||||
CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0),
|
||||
}
|
||||
|
||||
assert expected == GASES
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/mics_5524/mics_math.h"
|
||||
|
||||
namespace esphome::mics_5524::testing {
|
||||
|
||||
using micsmath::air_value_from_voltage;
|
||||
using micsmath::calibration_is_stable;
|
||||
using micsmath::ppm_from_vendor_curve;
|
||||
using micsmath::ratio_from_air_value;
|
||||
|
||||
// Independent copy of the DFRobot table; the Python tests pin coefficients.py itself.
|
||||
constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f};
|
||||
constexpr micsmath::VendorCurve H2{0.279f, 0.00026f, 1.0f, 1000.0f};
|
||||
constexpr micsmath::VendorCurve NH3{0.8f, 0.0015f, 10.0f, 500.0f};
|
||||
constexpr micsmath::VendorCurve C2H5OH{0.306f, 0.00057f, 10.0f, 500.0f};
|
||||
constexpr micsmath::VendorCurve CH4{0.786f, 0.000023f, 1000.0f, 25000.0f};
|
||||
|
||||
} // namespace esphome::mics_5524::testing
|
||||
@@ -0,0 +1,22 @@
|
||||
sensor:
|
||||
- platform: adc
|
||||
id: mics_5524_adc
|
||||
name: MiCS-5524 AO
|
||||
pin: ${mics_pin}
|
||||
|
||||
- platform: mics_5524
|
||||
id: mics_5524_module
|
||||
# 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit.
|
||||
voltage: mics_5524_adc
|
||||
voltage_multiplier: 1.5
|
||||
sample_count: 3
|
||||
warmup_time: 1s
|
||||
calibration:
|
||||
delay: 5s
|
||||
sample_count: 3
|
||||
sample_interval: 20ms
|
||||
carbon_monoxide:
|
||||
name: MiCS-5524 CO
|
||||
max_ppm: 500.0
|
||||
hydrogen:
|
||||
name: MiCS-5524 H2
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "../common.h"
|
||||
|
||||
namespace esphome::mics_5524::testing {
|
||||
|
||||
TEST(MicsMath, SaturatedSensorReportsTheTopOfTheRange) {
|
||||
// ratio 0 is V_AO at VCC. The vendor helpers return the top of the range there,
|
||||
// so a saturated sensor must not be converted into a clean-air reading.
|
||||
EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(0.0f, CO));
|
||||
EXPECT_FLOAT_EQ(H2.max_ppm, ppm_from_vendor_curve(0.0f, H2));
|
||||
EXPECT_FLOAT_EQ(NH3.max_ppm, ppm_from_vendor_curve(0.0f, NH3));
|
||||
EXPECT_FLOAT_EQ(C2H5OH.max_ppm, ppm_from_vendor_curve(0.0f, C2H5OH));
|
||||
EXPECT_FLOAT_EQ(CH4.max_ppm, ppm_from_vendor_curve(0.0f, CH4));
|
||||
}
|
||||
|
||||
TEST(MicsMath, GasFreeAirReadsZero) {
|
||||
// At or above the threshold the vendor reports no gas, and so does a value below
|
||||
// its own measuring range.
|
||||
EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold, CO));
|
||||
EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(CO.threshold + 0.01f, CO));
|
||||
EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(1.0f, CO));
|
||||
EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.4249f, CO)); // 0.25 ppm, below the range
|
||||
}
|
||||
|
||||
TEST(MicsMath, MidRangeReadingsFollowTheVendorFormula) {
|
||||
// (0.425 - 0.2) / 0.000405 = 555.56 ppm
|
||||
EXPECT_NEAR(555.556f, ppm_from_vendor_curve(0.2f, CO), 0.01f);
|
||||
// (0.8 - 0.7) / 0.0015 = 66.67 ppm
|
||||
EXPECT_NEAR(66.667f, ppm_from_vendor_curve(0.7f, NH3), 0.01f);
|
||||
// (0.279 - 0.1) / 0.00026 = 688.46 ppm
|
||||
EXPECT_NEAR(688.462f, ppm_from_vendor_curve(0.1f, H2), 0.01f);
|
||||
}
|
||||
|
||||
TEST(MicsMath, UnusableInputReadsUnknown) {
|
||||
EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(NAN, CO)));
|
||||
EXPECT_TRUE(std::isnan(ppm_from_vendor_curve(INFINITY, CO)));
|
||||
EXPECT_TRUE(std::isnan(air_value_from_voltage(NAN, 5.0f)));
|
||||
EXPECT_TRUE(std::isnan(air_value_from_voltage(4.0f, NAN)));
|
||||
EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, NAN)));
|
||||
EXPECT_TRUE(std::isnan(ratio_from_air_value(NAN, 1.0f)));
|
||||
// A missing clean-air reference (x_air = 0) must not be read as a real ratio.
|
||||
EXPECT_TRUE(std::isnan(ratio_from_air_value(1.0f, 0.0f)));
|
||||
}
|
||||
|
||||
TEST(MicsMath, AirValueIsTheVoltageBelowVcc) {
|
||||
EXPECT_FLOAT_EQ(1.0f, air_value_from_voltage(4.0f, 5.0f));
|
||||
// At VCC the vendor value is 0, the saturated end of the range ...
|
||||
EXPECT_FLOAT_EQ(0.0f, air_value_from_voltage(5.0f, 5.0f));
|
||||
// ... but a voltage above VCC cannot come from the sensor, so it is unusable rather
|
||||
// than saturated: a wrong `vcc:` is not reported as a real reading.
|
||||
EXPECT_TRUE(std::isnan(air_value_from_voltage(5.2f, 5.0f)));
|
||||
// A pin at or below 0 V is disconnected, not a reading of VCC.
|
||||
EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f)));
|
||||
EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f)));
|
||||
// The two ends of the vendor range, and the unusable case, one by one.
|
||||
EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f));
|
||||
EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f));
|
||||
EXPECT_TRUE(std::isnan(ratio_from_air_value(air_value_from_voltage(6.0f, 5.0f), 1.0f)));
|
||||
}
|
||||
|
||||
TEST(MicsMath, CalibrationSamplesMustAgree) {
|
||||
// Clean air at 3.6 V with a few millivolts of ADC noise: a settled reference.
|
||||
EXPECT_TRUE(calibration_is_stable(3.555f, 3.645f, 3.6f));
|
||||
// A sensor that is still settling or not in clean air: (4.9 - 0.1) / 2.5 = 1.92.
|
||||
EXPECT_FALSE(calibration_is_stable(0.1f, 4.9f, 2.5f));
|
||||
// The limit is inclusive: 0.25 of the mean passes, a little more does not.
|
||||
EXPECT_TRUE(calibration_is_stable(3.0f, 4.0f, 4.0f));
|
||||
EXPECT_FALSE(calibration_is_stable(2.9f, 4.0f, 4.0f));
|
||||
// Unusable values are never a settled reference.
|
||||
EXPECT_FALSE(calibration_is_stable(NAN, 3.6f, 3.6f));
|
||||
EXPECT_FALSE(calibration_is_stable(3.6f, NAN, 3.6f));
|
||||
EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, NAN));
|
||||
EXPECT_FALSE(calibration_is_stable(3.6f, 3.6f, 0.0f));
|
||||
}
|
||||
|
||||
TEST(MicsMath, ConfiguredCeilingCapsTheReading) {
|
||||
// `max_ppm:` narrows the vendor range: a reading above the ceiling is reported as
|
||||
// the ceiling, never as a value the sensor is not configured for.
|
||||
const micsmath::VendorCurve narrow{0.425f, 0.000405f, 1.0f, 500.0f};
|
||||
EXPECT_FLOAT_EQ(500.0f, ppm_from_vendor_curve(0.2f, narrow)); // 555.56 ppm
|
||||
EXPECT_FLOAT_EQ(0.0f, ppm_from_vendor_curve(0.5f, narrow)); // above the threshold
|
||||
}
|
||||
|
||||
TEST(MicsMath, SaturatedSensorEndToEnd) {
|
||||
// V_AO at VCC -> x = 0 -> ratio 0 -> top of the range, the case that used to read
|
||||
// 0 ppm and made a saturated sensor look like clean air.
|
||||
const float x = air_value_from_voltage(5.0f, 5.0f);
|
||||
const float ratio = ratio_from_air_value(x, 1.0f);
|
||||
|
||||
EXPECT_FLOAT_EQ(0.0f, ratio);
|
||||
EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO));
|
||||
}
|
||||
|
||||
} // namespace esphome::mics_5524::testing
|
||||
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
mics_pin: GPIO34
|
||||
|
||||
packages:
|
||||
mics_5524: !include common.yaml
|
||||
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
mics_pin: A0
|
||||
|
||||
packages:
|
||||
mics_5524: !include common.yaml
|
||||
@@ -0,0 +1,5 @@
|
||||
substitutions:
|
||||
mics_pin: GPIO26
|
||||
|
||||
packages:
|
||||
mics_5524: !include common.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
# Config-only test: the external voltage sampler path and the PPM range options.
|
||||
sensor:
|
||||
- platform: adc
|
||||
id: mics_5524_adc
|
||||
name: MiCS-5524 AO
|
||||
pin: GPIO35
|
||||
|
||||
- platform: mics_5524
|
||||
id: mics_5524_module
|
||||
voltage: mics_5524_adc
|
||||
voltage_multiplier: 1.5
|
||||
air_reference: 3.6
|
||||
vcc: 5.0
|
||||
sample_count: 2
|
||||
warmup_time: 30s
|
||||
carbon_monoxide:
|
||||
name: MiCS-5524 CO
|
||||
max_ppm: 1000.0
|
||||
hydrogen:
|
||||
name: MiCS-5524 H2
|
||||
ammonia:
|
||||
name: MiCS-5524 NH3
|
||||
max_ppm: 300.0
|
||||
Reference in New Issue
Block a user