mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee99bab08b | ||
|
|
a8a8acb918 | ||
|
|
6c1a7642f3 | ||
|
|
1ce0db00b3 | ||
|
|
a50a30414e |
@@ -13,7 +13,9 @@ from esphome.const import (
|
||||
CONF_MIN_POWER,
|
||||
CONF_POWER_SUPPLY,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.core.entity_helpers import ApplyEntity, SubEntities
|
||||
from esphome.cpp_generator import MockObj, SafeExpType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -54,6 +56,8 @@ BinaryOutput = output_ns.class_("BinaryOutput")
|
||||
BinaryOutputPtr = BinaryOutput.operator("ptr")
|
||||
FloatOutput = output_ns.class_("FloatOutput", BinaryOutput)
|
||||
FloatOutputPtr = FloatOutput.operator("ptr")
|
||||
ApplyBinaryOutput = output_ns.class_("ApplyBinaryOutput", BinaryOutput)
|
||||
ApplyFloatOutput = output_ns.class_("ApplyFloatOutput", FloatOutput)
|
||||
|
||||
|
||||
async def setup_output_platform_(obj, config):
|
||||
@@ -84,6 +88,39 @@ async def register_output(var, config):
|
||||
await setup_output_platform_(var, config)
|
||||
|
||||
|
||||
async def new_output(config: ConfigType, *args: SafeExpType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID], *args)
|
||||
await register_output(var, config)
|
||||
return var
|
||||
|
||||
|
||||
BINARY_APPLY_ENTITY = ApplyEntity(
|
||||
ApplyBinaryOutput,
|
||||
((BinaryOutputPtr, "entity"), (cg.uint16, "index"), (cg.bool_, "state")),
|
||||
)
|
||||
FLOAT_APPLY_ENTITY = ApplyEntity(
|
||||
ApplyFloatOutput,
|
||||
((FloatOutputPtr, "entity"), (cg.uint16, "index"), (cg.float_, "state")),
|
||||
)
|
||||
|
||||
|
||||
def sub_binary_outputs(
|
||||
config: ConfigType, *, parent: MockObj | ID | None = None
|
||||
) -> SubEntities:
|
||||
"""Return a SubEntities bound to new_output; ``apply`` can use ``entity``, ``index`` and ``state``.
|
||||
|
||||
``index`` is the constructor argument (default 0), so a hub's channels share one class.
|
||||
"""
|
||||
return SubEntities(new_output, config, parent, BINARY_APPLY_ENTITY)
|
||||
|
||||
|
||||
def sub_float_outputs(
|
||||
config: ConfigType, *, parent: MockObj | ID | None = None
|
||||
) -> SubEntities:
|
||||
"""Like sub_binary_outputs; ``apply`` gets the scaled level as ``state``."""
|
||||
return SubEntities(new_output, config, parent, FLOAT_APPLY_ENTITY)
|
||||
|
||||
|
||||
BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(BinaryOutput),
|
||||
|
||||
@@ -68,4 +68,16 @@ class BinaryOutput {
|
||||
#endif
|
||||
};
|
||||
|
||||
/// Calls the codegen-generated Fn(this, index, state) on a state write; the hub is baked into Fn.
|
||||
/// The index (a hub's channel) is data, so all channels of a hub share one instantiation.
|
||||
template<auto Fn> class ApplyBinaryOutput final : public BinaryOutput {
|
||||
public:
|
||||
explicit ApplyBinaryOutput(uint16_t index = 0) : index_(index) {}
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override { Fn(this, this->index_, state); }
|
||||
|
||||
uint16_t index_; // Fits in the base's tail padding
|
||||
};
|
||||
|
||||
} // namespace esphome::output
|
||||
|
||||
@@ -130,4 +130,16 @@ class FloatOutput : public BinaryOutput {
|
||||
#endif
|
||||
};
|
||||
|
||||
/// Calls the codegen-generated Fn(this, index, state) with the scaled, inverted level; the hub is baked into Fn.
|
||||
/// The index (a hub's channel) is data, so all channels of a hub share one instantiation.
|
||||
template<auto Fn> class ApplyFloatOutput final : public FloatOutput {
|
||||
public:
|
||||
explicit ApplyFloatOutput(uint16_t index = 0) : index_(index) {}
|
||||
|
||||
protected:
|
||||
void write_state(float state) override { Fn(this, this->index_, state); }
|
||||
|
||||
uint16_t index_; // Fits in the base's tail padding
|
||||
};
|
||||
|
||||
} // namespace esphome::output
|
||||
|
||||
@@ -39,13 +39,16 @@ from esphome.const import (
|
||||
CONF_REFERENCE_TEMPERATURE,
|
||||
CONF_SEND_EVERY,
|
||||
CONF_SEND_FIRST_AT,
|
||||
CONF_SENSOR_ID,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TEMPERATURE_OFFSET,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TO,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USE_FAHRENHEIT,
|
||||
CONF_VALUE,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WINDOW_SIZE,
|
||||
@@ -310,6 +313,7 @@ OrFilter = sensor_ns.class_("OrFilter", Filter)
|
||||
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
|
||||
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
|
||||
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
|
||||
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
|
||||
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
|
||||
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
|
||||
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
|
||||
@@ -1281,6 +1285,23 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
|
||||
)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"rh_correction",
|
||||
RHCorrectionFilter,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_),
|
||||
cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor),
|
||||
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
|
||||
sensor = await cg.get_variable(config[CONF_SENSOR_ID])
|
||||
offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_)
|
||||
return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT])
|
||||
|
||||
|
||||
def _mean(xs):
|
||||
return sum(xs) / len(xs)
|
||||
|
||||
|
||||
@@ -493,6 +493,48 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
|
||||
return temp;
|
||||
}
|
||||
|
||||
// RHCorrectionFilter
|
||||
static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f;
|
||||
|
||||
static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; }
|
||||
|
||||
optional<float> RHCorrectionFilter::new_value(float value) {
|
||||
if (!this->temperature_sensor_->has_state()) {
|
||||
if (!this->warned_) {
|
||||
ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this);
|
||||
this->warned_ = true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
float offset = this->temperature_offset_.value();
|
||||
float tc = this->temperature_sensor_->get_state();
|
||||
if (this->use_fahrenheit_) {
|
||||
tc = fahrenheit_to_celsius(tc);
|
||||
offset /= 1.8f;
|
||||
}
|
||||
const float tnc = tc - offset;
|
||||
// A NaN temperature or offset is a faulted input: keep the last corrected value
|
||||
if (std::isnan(tnc)) {
|
||||
if (!this->warned_) {
|
||||
ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this);
|
||||
this->warned_ = true;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) {
|
||||
if (!this->warned_) {
|
||||
ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc);
|
||||
this->warned_ = true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
this->warned_ = false;
|
||||
const float rh =
|
||||
clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f);
|
||||
ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh);
|
||||
return rh;
|
||||
}
|
||||
|
||||
// StreamingFilter (base class)
|
||||
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
|
||||
: window_size_(window_size), send_first_at_(send_first_at) {}
|
||||
|
||||
@@ -647,6 +647,26 @@ class ToNTCTemperatureFilter : public Filter {
|
||||
double c_;
|
||||
};
|
||||
|
||||
/** RH Temperature correction filter
|
||||
*
|
||||
* Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor.
|
||||
* Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C;
|
||||
* see https://en.wikipedia.org/wiki/Dew_point
|
||||
*/
|
||||
class RHCorrectionFilter : public Filter {
|
||||
public:
|
||||
RHCorrectionFilter(TemplatableFn<float> offset, const Sensor *temperature_sensor, bool use_fahrenheit)
|
||||
: temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {}
|
||||
|
||||
optional<float> new_value(float value) override;
|
||||
|
||||
protected:
|
||||
TemplatableFn<float> temperature_offset_;
|
||||
const Sensor *temperature_sensor_;
|
||||
bool use_fahrenheit_;
|
||||
bool warned_{false};
|
||||
};
|
||||
|
||||
/** Base class for streaming filters (batch windows where window_size == send_every).
|
||||
*
|
||||
* When window_size equals send_every, we don't need a sliding window.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: temperature
|
||||
filters:
|
||||
- offset: -7.3
|
||||
- platform: template
|
||||
id: constant_offset
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: temperature
|
||||
temperature_offset: -7.3
|
||||
- platform: template
|
||||
id: templated_offset
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: temperature
|
||||
temperature_offset: !lambda return -7.3;
|
||||
use_fahrenheit: true
|
||||
@@ -0,0 +1,60 @@
|
||||
"""The rh_correction filter: schema and the constructor call it generates."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.sensor import validate_filters
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filters",
|
||||
[
|
||||
pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"),
|
||||
pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"),
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"rh_correction": {
|
||||
"sensor_id": "t",
|
||||
"temperature_offset": -7.3,
|
||||
"use_fahrenheit": "maybe",
|
||||
}
|
||||
}
|
||||
],
|
||||
id="bad_fahrenheit",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
validate_filters(filters)
|
||||
|
||||
|
||||
def test_rh_correction_defaults_to_celsius() -> None:
|
||||
config = validate_filters(
|
||||
[{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}]
|
||||
)
|
||||
assert config[0]["rh_correction"]["use_fahrenheit"] is False
|
||||
|
||||
|
||||
def test_rh_correction_codegen(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml")
|
||||
|
||||
# A constant offset is wrapped in a stateless lambda so the filter stores a function pointer
|
||||
assert re.search(
|
||||
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, "
|
||||
r"temperature, false\);\s*constant_offset->add_filter\(",
|
||||
main_cpp,
|
||||
)
|
||||
# A templated offset keeps the user's lambda body and passes use_fahrenheit through
|
||||
assert re.search(
|
||||
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, "
|
||||
r"temperature, true\);\s*templated_offset->add_filter\(",
|
||||
main_cpp,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# Builds the min/max power scaling so the Apply output tests cover it
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,81 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/output/binary_output.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
|
||||
namespace esphome::output::testing {
|
||||
|
||||
static void *last_output = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint16_t last_index = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static std::vector<bool> bool_writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static std::vector<float> levels; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void on_binary_write(BinaryOutput *output, uint16_t index, bool state) {
|
||||
last_output = output;
|
||||
last_index = index;
|
||||
bool_writes.push_back(state);
|
||||
}
|
||||
|
||||
static void on_float_write(FloatOutput *output, uint16_t index, float state) {
|
||||
last_output = output;
|
||||
last_index = index;
|
||||
levels.push_back(state);
|
||||
}
|
||||
|
||||
// The index sits in the base's tail padding; only the power supply requester leaves none.
|
||||
#ifndef USE_POWER_SUPPLY
|
||||
static_assert(sizeof(ApplyBinaryOutput<on_binary_write>) == sizeof(BinaryOutput));
|
||||
static_assert(sizeof(ApplyFloatOutput<on_float_write>) == sizeof(FloatOutput));
|
||||
#endif
|
||||
|
||||
static void reset() {
|
||||
last_output = nullptr;
|
||||
last_index = 0;
|
||||
bool_writes.clear();
|
||||
levels.clear();
|
||||
}
|
||||
|
||||
TEST(ApplyBinaryOutput, WritesReachTheFunctionWithIndexAndInversion) {
|
||||
reset();
|
||||
ApplyBinaryOutput<on_binary_write> out(7);
|
||||
out.turn_on();
|
||||
out.turn_off();
|
||||
out.set_inverted(true);
|
||||
out.turn_on();
|
||||
EXPECT_EQ(bool_writes, (std::vector<bool>{true, false, false}));
|
||||
EXPECT_EQ(last_output, &out);
|
||||
EXPECT_EQ(last_index, 7);
|
||||
}
|
||||
|
||||
TEST(ApplyFloatOutput, WritesReachTheFunctionWithIndexInversionAndClamp) {
|
||||
reset();
|
||||
ApplyFloatOutput<on_float_write> out(3);
|
||||
out.set_level(0.25f);
|
||||
out.turn_on();
|
||||
out.set_inverted(true);
|
||||
out.set_level(0.25f);
|
||||
out.set_level(2.0f);
|
||||
EXPECT_EQ(levels, (std::vector<float>{0.25f, 1.0f, 0.75f, 0.0f}));
|
||||
EXPECT_EQ(last_output, &out);
|
||||
EXPECT_EQ(last_index, 3);
|
||||
}
|
||||
|
||||
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
|
||||
TEST(ApplyFloatOutput, PowerScalingIsAppliedBeforeTheFunction) {
|
||||
reset();
|
||||
ApplyFloatOutput<on_float_write> out;
|
||||
out.set_min_power(0.2f);
|
||||
out.set_max_power(0.8f);
|
||||
out.set_zero_means_zero(true);
|
||||
out.set_level(0.5f);
|
||||
out.turn_off();
|
||||
ASSERT_EQ(levels.size(), 2u);
|
||||
EXPECT_FLOAT_EQ(levels[0], 0.5f);
|
||||
EXPECT_EQ(levels[1], 0.0f);
|
||||
EXPECT_EQ(last_index, 0);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::output::testing
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# filter.cpp only compiles with the define the real to_code emits for configured filters
|
||||
cg.add_define("USE_SENSOR_FILTER")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -254,3 +254,21 @@ sensor:
|
||||
lambda: return 42.0;
|
||||
update_interval: 1s
|
||||
state_class: "measurement_angle"
|
||||
|
||||
# Testing rh_correction filter
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "RH Correction Filter Fahrenheit"
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: source_sensor
|
||||
temperature_offset: -10.0
|
||||
use_fahrenheit: true
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "RH Correction Filter Template"
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: source_sensor
|
||||
temperature_offset: !lambda return -10.0;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/sensor/filter.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::sensor::testing {
|
||||
|
||||
// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic
|
||||
static constexpr float CORRECTED = 61.13f;
|
||||
|
||||
struct RhFixture {
|
||||
Sensor temperature;
|
||||
RHCorrectionFilter filter;
|
||||
|
||||
RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {}
|
||||
};
|
||||
|
||||
TEST(RHCorrectionFilter, CorrectsForTheOffset) {
|
||||
RhFixture f([]() -> float { return -7.3f; });
|
||||
f.temperature.publish_state(25.0f);
|
||||
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
|
||||
}
|
||||
|
||||
TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) {
|
||||
RhFixture f([]() -> float { return 7.3f; });
|
||||
f.temperature.publish_state(25.0f);
|
||||
EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
|
||||
}
|
||||
|
||||
TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) {
|
||||
// 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset
|
||||
RhFixture f([]() -> float { return -13.14f; }, true);
|
||||
f.temperature.publish_state(77.0f);
|
||||
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
|
||||
}
|
||||
|
||||
TEST(RHCorrectionFilter, ClampsToOneHundredPercent) {
|
||||
RhFixture f([]() -> float { return -7.3f; });
|
||||
f.temperature.publish_state(25.0f);
|
||||
EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f);
|
||||
}
|
||||
|
||||
TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) {
|
||||
RhFixture f([]() -> float { return -7.3f; });
|
||||
// No temperature reading yet
|
||||
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
|
||||
// Out of the -40..50 °C band
|
||||
f.temperature.publish_state(60.0f);
|
||||
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
|
||||
// The band also covers the reconstructed enclosure temperature
|
||||
f.temperature.publish_state(45.0f);
|
||||
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
|
||||
}
|
||||
|
||||
TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
|
||||
RhFixture f([]() -> float { return -7.3f; });
|
||||
f.temperature.publish_state(NAN);
|
||||
EXPECT_FALSE(f.filter.new_value(40.0f).has_value());
|
||||
|
||||
RhFixture nan_offset([]() -> float { return NAN; });
|
||||
nan_offset.temperature.publish_state(25.0f);
|
||||
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
|
||||
}
|
||||
|
||||
} // namespace esphome::sensor::testing
|
||||
@@ -0,0 +1,102 @@
|
||||
esphome:
|
||||
name: test-rh-correction
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Temperature C"
|
||||
id: temperature_c
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: template
|
||||
name: "Temperature F"
|
||||
id: temperature_f
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: template
|
||||
name: "Humidity Source"
|
||||
id: humidity_source
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: humidity_source
|
||||
name: "RH Celsius"
|
||||
id: rh_celsius
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: temperature_c
|
||||
temperature_offset: -7.3
|
||||
|
||||
- platform: copy
|
||||
source_id: humidity_source
|
||||
name: "RH Fahrenheit"
|
||||
id: rh_fahrenheit
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
- rh_correction:
|
||||
sensor_id: temperature_f
|
||||
temperature_offset: -13.14
|
||||
use_fahrenheit: true
|
||||
|
||||
script:
|
||||
- id: test_rh_correction
|
||||
then:
|
||||
# Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset
|
||||
- sensor.template.publish:
|
||||
id: temperature_c
|
||||
state: 25.0
|
||||
- sensor.template.publish:
|
||||
id: temperature_f
|
||||
state: 77.0
|
||||
- sensor.template.publish:
|
||||
id: humidity_source
|
||||
state: 40.0
|
||||
- delay: 20ms
|
||||
# Clamped to 100 %
|
||||
- sensor.template.publish:
|
||||
id: humidity_source
|
||||
state: 90.0
|
||||
- delay: 20ms
|
||||
# Out of band: passes through unchanged
|
||||
- sensor.template.publish:
|
||||
id: temperature_c
|
||||
state: 60.0
|
||||
- sensor.template.publish:
|
||||
id: temperature_f
|
||||
state: 140.0
|
||||
- sensor.template.publish:
|
||||
id: humidity_source
|
||||
state: 40.0
|
||||
- delay: 20ms
|
||||
# NaN temperature: the update is dropped
|
||||
- sensor.template.publish:
|
||||
id: temperature_c
|
||||
state: !lambda "return NAN;"
|
||||
- sensor.template.publish:
|
||||
id: temperature_f
|
||||
state: !lambda "return NAN;"
|
||||
- sensor.template.publish:
|
||||
id: humidity_source
|
||||
state: 50.0
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: temperature_c
|
||||
state: 25.0
|
||||
- sensor.template.publish:
|
||||
id: temperature_f
|
||||
state: 77.0
|
||||
- sensor.template.publish:
|
||||
id: humidity_source
|
||||
state: 40.0
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Test RH Correction"
|
||||
on_press:
|
||||
- script.execute: test_rh_correction
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Test the sensor rh_correction filter numerically."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
# 40 % at 25 °C, measured 7.3 °C warmer, in single precision Magnus arithmetic
|
||||
CORRECTED = pytest.approx(61.13, abs=0.05)
|
||||
# Corrected, clamped, passed through out of band, corrected again; the NaN step is dropped
|
||||
EXPECTED = [CORRECTED, 100.0, 40.0, CORRECTED]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_filters_rh_correction(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
collector = SensorStateCollector(sensor_names=["rh_celsius", "rh_fahrenheit"])
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
done = collector.add_waiter(
|
||||
lambda: all(
|
||||
len(values) == len(EXPECTED)
|
||||
for values in collector.sensor_states.values()
|
||||
)
|
||||
)
|
||||
button = require_entity(entities, "test_rh_correction", ButtonInfo, exact=True)
|
||||
client.button_command(button.key)
|
||||
await wait_or_fail(
|
||||
done,
|
||||
2.0,
|
||||
"Timed out waiting for the corrected humidity values",
|
||||
values=collector.sensor_states,
|
||||
)
|
||||
|
||||
assert collector.sensor_states["rh_celsius"] == EXPECTED
|
||||
# The same readings in °F give the same corrected humidity
|
||||
assert collector.sensor_states["rh_fahrenheit"] == EXPECTED
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
from esphome import final_validate as fv
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button, number, select, switch
|
||||
from esphome.components import button, number, output, select, switch
|
||||
from esphome.config import Config
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import (
|
||||
@@ -1466,6 +1466,30 @@ async def test_sub_entities_apply_shares_equal_functions(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_outputs_share_one_function_per_hub(
|
||||
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
|
||||
) -> None:
|
||||
"""A hub's channels pass their channel as the index, so they share one function."""
|
||||
calls: list[tuple[Any, ...]] = []
|
||||
cls = output.ApplyFloatOutput
|
||||
config = {"a": _apply_conf("ch0", cls), "b": _apply_conf("ch5", cls)}
|
||||
sub = _apply_sub(calls, config, output.FLOAT_APPLY_ENTITY)
|
||||
await sub("a", None, 0, apply="write_channel(index, state)")
|
||||
await sub("b", None, 5, apply="write_channel(index, state)")
|
||||
assert calls == [
|
||||
("ch0", "<esphome__ch0__fn>", "0"),
|
||||
("ch5", "<esphome__ch0__fn>", "5"),
|
||||
]
|
||||
assert apply_functions == [
|
||||
(
|
||||
"esphome__ch0__fn",
|
||||
"output::FloatOutput * entity, uint16_t index, float state",
|
||||
"::hub->write_channel(index, state);",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_entities_apply_parent_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1567,6 +1591,18 @@ async def test_sub_entities_apply_needs_parent(
|
||||
"select::Select * entity, size_t index",
|
||||
"entity->publish_state(index);",
|
||||
),
|
||||
(
|
||||
output.sub_binary_outputs,
|
||||
output.ApplyBinaryOutput,
|
||||
"output::BinaryOutput * entity, uint16_t index, bool state",
|
||||
None,
|
||||
),
|
||||
(
|
||||
output.sub_float_outputs,
|
||||
output.ApplyFloatOutput,
|
||||
"output::FloatOutput * entity, uint16_t index, float state",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_domain_apply_entities(
|
||||
|
||||
Reference in New Issue
Block a user