From da9c755f6730f38f173fafceac6848d813cca9df Mon Sep 17 00:00:00 2001 From: Ralf Habacker Date: Thu, 1 May 2025 09:53:12 +0200 Subject: [PATCH] Add to_ntc_resistance|temperature sensor filter (esphome/feature-requests#2967) (#7898) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/sensor/__init__.py | 139 ++++++++++++++++++++++++++ esphome/components/sensor/filter.cpp | 23 +++++ esphome/components/sensor/filter.h | 22 ++++ esphome/const.py | 2 + tests/components/template/common.yaml | 10 ++ 5 files changed, 196 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 9dbad27102..5f990466c8 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,3 +1,4 @@ +import logging import math from esphome import automation @@ -9,6 +10,7 @@ from esphome.const import ( CONF_ACCURACY_DECIMALS, CONF_ALPHA, CONF_BELOW, + CONF_CALIBRATION, CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_EXPIRE_AFTER, @@ -30,6 +32,7 @@ from esphome.const import ( CONF_SEND_EVERY, CONF_SEND_FIRST_AT, CONF_STATE_CLASS, + CONF_TEMPERATURE, CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, @@ -153,6 +156,8 @@ DEVICE_CLASSES = [ DEVICE_CLASS_WIND_SPEED, ] +_LOGGER = logging.getLogger(__name__) + sensor_ns = cg.esphome_ns.namespace("sensor") StateClasses = sensor_ns.enum("StateClass") STATE_CLASSES = { @@ -246,6 +251,8 @@ HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) OrFilter = sensor_ns.class_("OrFilter", Filter) CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter) +ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter) +ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter) CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter) SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter) ClampFilter = sensor_ns.class_("ClampFilter", Filter) @@ -852,6 +859,138 @@ async def sensor_in_range_to_code(config, condition_id, template_arg, args): return var +def validate_ntc_calibration_parameter(value): + if isinstance(value, dict): + return cv.Schema( + { + cv.Required(CONF_TEMPERATURE): cv.temperature, + cv.Required(CONF_VALUE): cv.resistance, + } + )(value) + + value = cv.string(value) + parts = value.split("->") + if len(parts) != 2: + raise cv.Invalid("Calibration parameter must be of form 3000 -> 23°C") + resistance = cv.resistance(parts[0].strip()) + temperature = cv.temperature(parts[1].strip()) + return validate_ntc_calibration_parameter( + { + CONF_TEMPERATURE: temperature, + CONF_VALUE: resistance, + } + ) + + +CONF_A = "a" +CONF_B = "b" +CONF_C = "c" +ZERO_POINT = 273.15 + + +def ntc_calc_steinhart_hart(value): + r1 = value[0][CONF_VALUE] + r2 = value[1][CONF_VALUE] + r3 = value[2][CONF_VALUE] + t1 = value[0][CONF_TEMPERATURE] + ZERO_POINT + t2 = value[1][CONF_TEMPERATURE] + ZERO_POINT + t3 = value[2][CONF_TEMPERATURE] + ZERO_POINT + + l1 = math.log(r1) + l2 = math.log(r2) + l3 = math.log(r3) + + y1 = 1 / t1 + y2 = 1 / t2 + y3 = 1 / t3 + + g2 = (y2 - y1) / (l2 - l1) + g3 = (y3 - y1) / (l3 - l1) + + c = (g3 - g2) / (l3 - l2) * 1 / (l1 + l2 + l3) + b = g2 - c * (l1 * l1 + l1 * l2 + l2 * l2) + a = y1 - (b + l1 * l1 * c) * l1 + return a, b, c + + +def ntc_get_abc(value): + a = value[CONF_A] + b = value[CONF_B] + c = value[CONF_C] + return a, b, c + + +def ntc_process_calibration(value): + if isinstance(value, dict): + value = cv.Schema( + { + cv.Required(CONF_A): cv.float_, + cv.Required(CONF_B): cv.float_, + cv.Required(CONF_C): cv.float_, + } + )(value) + a, b, c = ntc_get_abc(value) + elif isinstance(value, list): + if len(value) != 3: + raise cv.Invalid( + "Steinhart–Hart Calibration must consist of exactly three values" + ) + value = cv.Schema([validate_ntc_calibration_parameter])(value) + a, b, c = ntc_calc_steinhart_hart(value) + else: + raise cv.Invalid( + f"Calibration parameter accepts either a list for steinhart-hart calibration, or mapping for b-constant calibration, not {type(value)}" + ) + _LOGGER.info("Coefficient: a:%s, b:%s, c:%s", a, b, c) + return { + CONF_A: a, + CONF_B: b, + CONF_C: c, + } + + +@FILTER_REGISTRY.register( + "to_ntc_resistance", + ToNTCResistanceFilter, + cv.All( + cv.Schema( + { + cv.Required(CONF_CALIBRATION): ntc_process_calibration, + } + ), + ), +) +async def calibrate_ntc_resistance_filter_to_code(config, filter_id): + calib = config[CONF_CALIBRATION] + return cg.new_Pvariable( + filter_id, + calib[CONF_A], + calib[CONF_B], + calib[CONF_C], + ) + + +@FILTER_REGISTRY.register( + "to_ntc_temperature", + ToNTCTemperatureFilter, + cv.All( + cv.Schema( + { + cv.Required(CONF_CALIBRATION): ntc_process_calibration, + } + ), + ), +) +async def calibrate_ntc_temperature_filter_to_code(config, filter_id): + calib = config[CONF_CALIBRATION] + return cg.new_Pvariable( + filter_id, + calib[CONF_A], + calib[CONF_B], + calib[CONF_C], + ) + + def _mean(xs): return sum(xs) / len(xs) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0a8740dd5b..ce23c1f800 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -481,5 +481,28 @@ optional RoundMultipleFilter::new_value(float value) { return value; } +optional ToNTCResistanceFilter::new_value(float value) { + if (!std::isfinite(value)) { + return NAN; + } + double k = 273.15; + // https://de.wikipedia.org/wiki/Steinhart-Hart-Gleichung#cite_note-stein2_s4-3 + double t = value + k; + double y = (this->a_ - 1 / (t)) / (2 * this->c_); + double x = sqrt(pow(this->b_ / (3 * this->c_), 3) + y * y); + double resistance = exp(pow(x - y, 1 / 3.0) - pow(x + y, 1 / 3.0)); + return resistance; +} + +optional ToNTCTemperatureFilter::new_value(float value) { + if (!std::isfinite(value)) { + return NAN; + } + double lr = log(double(value)); + double v = this->a_ + this->b_ * lr + this->c_ * lr * lr * lr; + double temp = float(1.0 / v - 273.15); + return temp; +} + } // namespace sensor } // namespace esphome diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 86586b458d..3cfaebb708 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -439,5 +439,27 @@ class RoundMultipleFilter : public Filter { float multiple_; }; +class ToNTCResistanceFilter : public Filter { + public: + ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {} + optional new_value(float value) override; + + protected: + double a_; + double b_; + double c_; +}; + +class ToNTCTemperatureFilter : public Filter { + public: + ToNTCTemperatureFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {} + optional new_value(float value) override; + + protected: + double a_; + double b_; + double c_; +}; + } // namespace sensor } // namespace esphome diff --git a/esphome/const.py b/esphome/const.py index 21cf7367de..f78312a5b0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -897,6 +897,8 @@ CONF_TIMES = "times" CONF_TIMEZONE = "timezone" CONF_TIMING = "timing" CONF_TO = "to" +CONF_TO_NTC_RESISTANCE = "to_ntc_resistance" +CONF_TO_NTC_TEMPERATURE = "to_ntc_temperature" CONF_TOLERANCE = "tolerance" CONF_TOPIC = "topic" CONF_TOPIC_PREFIX = "topic_prefix" diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index 79201fbe07..987849a80c 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -28,6 +28,16 @@ sensor: value: 20.0 - timeout: timeout: 1d + - to_ntc_resistance: + calibration: + - 10.0kOhm -> 25°C + - 27.219kOhm -> 0°C + - 14.674kOhm -> 15°C + - to_ntc_temperature: + calibration: + - 10.0kOhm -> 25°C + - 27.219kOhm -> 0°C + - 14.674kOhm -> 15°C esphome: on_boot: