mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[filter_lifetime] Add component with sensor/lambda support for is_on … (#13914)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
J. Nick Koston
parent
c1c1d2b2ae
commit
4a80d785a8
@@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/filter_lifetime/* @tuct
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/friedrich/* @rwrozelle
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@tuct"]
|
||||
DOMAIN = "filter_lifetime"
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "filter_lifetime.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::filter_lifetime {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "filter_lifetime");
|
||||
|
||||
static constexpr uint32_t SECONDS_PER_HOUR = 3600;
|
||||
static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f;
|
||||
|
||||
void FilterLifetime::setup() {
|
||||
this->pref_ = this->make_entity_preference<uint32_t>();
|
||||
this->pref_.load(&this->runtime_seconds_);
|
||||
this->last_update_ = millis();
|
||||
this->publish_values_();
|
||||
}
|
||||
|
||||
void FilterLifetime::update() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const uint32_t elapsed_ms = now - this->last_update_;
|
||||
this->last_update_ = now;
|
||||
|
||||
if (this->get_is_on_()) {
|
||||
float speed = this->get_current_speed_();
|
||||
if (std::isnan(speed))
|
||||
speed = 0.0f;
|
||||
speed = std::clamp(speed, 0.0f, 100.0f);
|
||||
const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR;
|
||||
this->carry_ms_ += static_cast<uint32_t>(elapsed_ms * (speed / 100.0f));
|
||||
this->runtime_seconds_ += this->carry_ms_ / 1000;
|
||||
this->carry_ms_ %= 1000;
|
||||
// Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266.
|
||||
if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours)
|
||||
this->pref_.save(&this->runtime_seconds_);
|
||||
ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_);
|
||||
}
|
||||
this->publish_values_();
|
||||
}
|
||||
|
||||
void FilterLifetime::publish_values_() {
|
||||
const float runtime_minutes = this->runtime_seconds_ / 60.0f;
|
||||
const float max_minutes = this->max_lifetime_minutes_;
|
||||
const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes);
|
||||
this->publish_state(100.0f * remaining_minutes / max_minutes);
|
||||
if (this->runtime_hours_sensor_ != nullptr)
|
||||
this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f);
|
||||
if (this->remaining_days_sensor_ != nullptr)
|
||||
this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY);
|
||||
}
|
||||
|
||||
void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); }
|
||||
|
||||
void FilterLifetime::reset_filter() {
|
||||
this->runtime_seconds_ = 0;
|
||||
this->carry_ms_ = 0;
|
||||
this->pref_.save(&this->runtime_seconds_);
|
||||
this->publish_values_();
|
||||
ESP_LOGI(TAG, "Filter lifetime reset");
|
||||
}
|
||||
|
||||
bool FilterLifetime::get_is_on_() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
// Reads false until the sensor first publishes, so nothing accumulates before then.
|
||||
if (this->is_on_sensor_ != nullptr)
|
||||
return this->is_on_sensor_->state;
|
||||
#endif
|
||||
if (this->is_on_lambda_ != nullptr)
|
||||
return this->is_on_lambda_();
|
||||
return true;
|
||||
}
|
||||
|
||||
float FilterLifetime::get_current_speed_() {
|
||||
if (this->current_speed_sensor_ != nullptr)
|
||||
return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0
|
||||
if (this->current_speed_lambda_ != nullptr)
|
||||
return this->current_speed_lambda_();
|
||||
return 100.0f;
|
||||
}
|
||||
|
||||
void FilterLifetime::dump_config() {
|
||||
LOG_SENSOR("", "Filter Lifetime", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Max lifetime: %" PRIu32 " min (%.1f days)\n"
|
||||
" Runtime: %.1f h",
|
||||
this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY,
|
||||
this->runtime_seconds_ / 3600.0f);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->is_on_sensor_ != nullptr) {
|
||||
LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
ESP_LOGCONFIG(TAG, " Is-on: %s",
|
||||
this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always"));
|
||||
}
|
||||
if (this->current_speed_sensor_ != nullptr) {
|
||||
LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Speed: %s",
|
||||
this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%"));
|
||||
}
|
||||
LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_);
|
||||
LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
} // namespace esphome::filter_lifetime
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::filter_lifetime {
|
||||
|
||||
class FilterLifetime : public sensor::Sensor, public PollingComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
void reset_filter();
|
||||
|
||||
void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; }
|
||||
void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; }
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; }
|
||||
#endif
|
||||
void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; }
|
||||
void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; }
|
||||
void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; }
|
||||
void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; }
|
||||
|
||||
protected:
|
||||
bool get_is_on_();
|
||||
float get_current_speed_();
|
||||
void publish_values_();
|
||||
|
||||
bool (*is_on_lambda_)(){nullptr};
|
||||
float (*current_speed_lambda_)(){nullptr};
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *is_on_sensor_{nullptr};
|
||||
#endif
|
||||
sensor::Sensor *current_speed_sensor_{nullptr};
|
||||
sensor::Sensor *runtime_hours_sensor_{nullptr};
|
||||
sensor::Sensor *remaining_days_sensor_{nullptr};
|
||||
ESPPreferenceObject pref_;
|
||||
uint32_t max_lifetime_minutes_{0};
|
||||
uint32_t last_update_{0};
|
||||
uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted
|
||||
uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second
|
||||
};
|
||||
|
||||
} // namespace esphome::filter_lifetime
|
||||
@@ -0,0 +1,94 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_DURATION,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_HOUR,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_MAX_LIFETIME = "max_lifetime"
|
||||
CONF_IS_ON = "is_on"
|
||||
CONF_IS_ON_SENSOR = "is_on_sensor"
|
||||
CONF_CURRENT_SPEED = "current_speed"
|
||||
CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor"
|
||||
CONF_RUNTIME_HOURS = "runtime_hours"
|
||||
CONF_REMAINING_DAYS = "remaining_days"
|
||||
UNIT_DAY = "d"
|
||||
|
||||
filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime")
|
||||
FilterLifetime = filter_lifetime_ns.class_(
|
||||
"FilterLifetime", sensor.Sensor, cg.PollingComponent
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
FilterLifetime,
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=2,
|
||||
icon="mdi:air-filter",
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_MAX_LIFETIME): cv.All(
|
||||
cv.positive_time_period_minutes,
|
||||
cv.Range(min=cv.TimePeriod(minutes=1)),
|
||||
),
|
||||
cv.Optional(CONF_IS_ON): cv.returning_lambda,
|
||||
cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor),
|
||||
cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda,
|
||||
cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_HOUR,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
icon="mdi:timer-outline",
|
||||
),
|
||||
cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DAY,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
icon="mdi:calendar-clock",
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s")),
|
||||
cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR),
|
||||
cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME]))
|
||||
|
||||
if (is_on := config.get(CONF_IS_ON)) is not None:
|
||||
cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool)))
|
||||
elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None:
|
||||
cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor)))
|
||||
|
||||
if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None:
|
||||
cg.add(
|
||||
var.set_current_speed_lambda(await cg.templatable(current_speed, [], float))
|
||||
)
|
||||
elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None:
|
||||
cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor)))
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor)
|
||||
await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"filter_lifetime.reset_filter",
|
||||
cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}),
|
||||
automation.ApplyCall("reset_filter()"),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
from esphome.core import CORE
|
||||
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:
|
||||
for platform in ("sensor", "binary_sensor"):
|
||||
CORE.testing_ensure_platform_registered(platform)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
|
||||
@@ -0,0 +1,62 @@
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Fan Running"
|
||||
id: fan_running
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Fan Speed Percent"
|
||||
id: fan_speed_percent
|
||||
unit_of_measurement: "%"
|
||||
|
||||
# Test with lambdas
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Lambda"
|
||||
id: filter_lifetime_lambda
|
||||
max_lifetime: 365d
|
||||
is_on: !lambda 'return true;'
|
||||
current_speed: !lambda 'return 50.0;'
|
||||
update_interval: 60s
|
||||
runtime_hours:
|
||||
name: "Filter Runtime Hours Lambda"
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Lambda"
|
||||
|
||||
# Test with sensor references
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Sensor"
|
||||
id: filter_lifetime_sensor
|
||||
max_lifetime: 365d
|
||||
is_on_sensor: fan_running
|
||||
current_speed_sensor: fan_speed_percent
|
||||
update_interval: 60s
|
||||
runtime_hours:
|
||||
name: "Filter Runtime Hours Sensor"
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Sensor"
|
||||
|
||||
# Test with defaults (always on at 100%)
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Default"
|
||||
id: filter_lifetime_default
|
||||
max_lifetime: 180d
|
||||
update_interval: 60s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Reset Filter Lambda"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_lambda
|
||||
|
||||
- platform: template
|
||||
name: "Reset Filter Sensor"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_sensor
|
||||
|
||||
- platform: template
|
||||
name: "Reset Filter Default"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_default
|
||||
@@ -0,0 +1,8 @@
|
||||
sensor:
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Lambda Only"
|
||||
max_lifetime: 180d
|
||||
is_on: !lambda 'return true;'
|
||||
current_speed: !lambda 'return 75.0f;'
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Lambda Only"
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
filter_lifetime: !include lambda_only.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -0,0 +1,224 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/filter_lifetime/filter_lifetime.h"
|
||||
#include "esphome/components/host/preferences.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::filter_lifetime::testing {
|
||||
|
||||
class EntityIdentity : public EntityBase {
|
||||
public:
|
||||
static void configure(EntityBase &entity, const char *name, uint32_t hash) {
|
||||
auto configure = &EntityIdentity::configure_entity_;
|
||||
(entity.*configure)(name, hash, 0);
|
||||
}
|
||||
};
|
||||
|
||||
class TestableFilterLifetime : public FilterLifetime {
|
||||
public:
|
||||
using FilterLifetime::last_update_;
|
||||
using FilterLifetime::runtime_seconds_;
|
||||
};
|
||||
|
||||
static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static constexpr uint32_t MAX_MINUTES = 600; // 10 hours
|
||||
static constexpr uint32_t FILTER_HASH = 0x104001;
|
||||
|
||||
class FilterLifetimeTest : public ::testing::Test {
|
||||
protected:
|
||||
TestableFilterLifetime filter_;
|
||||
sensor::Sensor runtime_hours_;
|
||||
sensor::Sensor remaining_days_;
|
||||
float last_state_{NAN};
|
||||
unsigned publications_{0};
|
||||
|
||||
void SetUp() override {
|
||||
new (&App) Application;
|
||||
App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21);
|
||||
host::setup_preferences();
|
||||
host::host_preferences->reset();
|
||||
g_is_on = true;
|
||||
g_speed = 100.0f;
|
||||
this->attach(this->filter_);
|
||||
this->filter_.add_on_state_callback([this](float value) {
|
||||
this->last_state_ = value;
|
||||
++this->publications_;
|
||||
});
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
host::host_preferences->reset();
|
||||
App.~Application();
|
||||
}
|
||||
|
||||
void attach(FilterLifetime &filter) {
|
||||
EntityIdentity::configure(filter, "Filter", FILTER_HASH);
|
||||
filter.set_max_lifetime(MAX_MINUTES);
|
||||
filter.set_is_on_lambda([]() { return g_is_on; });
|
||||
filter.set_current_speed_lambda([]() { return g_speed; });
|
||||
filter.set_runtime_hours_sensor(&this->runtime_hours_);
|
||||
filter.set_remaining_days_sensor(&this->remaining_days_);
|
||||
}
|
||||
|
||||
// Run one update() as if elapsed_ms passed since the previous one
|
||||
void elapse(uint32_t elapsed_ms) {
|
||||
this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms;
|
||||
this->filter_.update();
|
||||
}
|
||||
|
||||
bool stored(uint32_t &value) { return this->filter_.make_entity_preference<uint32_t>().load(&value); }
|
||||
|
||||
static float expected_percent(uint32_t runtime_seconds) {
|
||||
return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) {
|
||||
this->filter_.setup();
|
||||
EXPECT_EQ(this->publications_, 1u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) {
|
||||
this->filter_.setup();
|
||||
g_speed = 50.0f;
|
||||
this->elapse(60000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 30u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30));
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) {
|
||||
this->filter_.setup();
|
||||
g_speed = 25.0f;
|
||||
for (int i = 0; i < 3; i++)
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) {
|
||||
this->filter_.setup();
|
||||
g_is_on = false;
|
||||
this->elapse(60000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
EXPECT_EQ(this->publications_, 2u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) {
|
||||
this->filter_.setup();
|
||||
g_speed = 250.0f;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
g_speed = NAN;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
g_speed = -5.0f;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) {
|
||||
sensor::Sensor speed;
|
||||
this->filter_.set_current_speed_lambda(nullptr);
|
||||
this->filter_.set_current_speed_sensor(&speed);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
speed.state = 50.0f;
|
||||
this->elapse(2000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) {
|
||||
binary_sensor::BinarySensor running;
|
||||
this->filter_.set_is_on_lambda(nullptr);
|
||||
this->filter_.set_is_on_sensor(&running);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
running.publish_state(true);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
running.publish_state(false);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) {
|
||||
this->filter_.set_is_on_lambda(nullptr);
|
||||
this->filter_.set_current_speed_lambda(nullptr);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) {
|
||||
this->filter_.setup();
|
||||
uint32_t value = 0;
|
||||
this->elapse(3599000);
|
||||
EXPECT_FALSE(this->stored(value));
|
||||
this->elapse(1000);
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 3600u);
|
||||
this->elapse(1000);
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 3600u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) {
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
this->filter_.on_shutdown();
|
||||
uint32_t value = 0;
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) {
|
||||
this->filter_.setup();
|
||||
this->elapse(1234000);
|
||||
this->filter_.on_shutdown();
|
||||
|
||||
TestableFilterLifetime restored;
|
||||
this->attach(restored);
|
||||
restored.setup();
|
||||
EXPECT_EQ(restored.runtime_seconds_, 1234u);
|
||||
EXPECT_FLOAT_EQ(restored.state, expected_percent(1234));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) {
|
||||
this->filter_.setup();
|
||||
this->elapse(5000);
|
||||
this->filter_.reset_filter();
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
|
||||
uint32_t value = 1;
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 0u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) {
|
||||
this->filter_.set_max_lifetime(1);
|
||||
this->filter_.setup();
|
||||
this->elapse(120000);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f);
|
||||
}
|
||||
|
||||
} // namespace esphome::filter_lifetime::testing
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for the filter_lifetime configuration schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.filter_lifetime import sensor as filter_lifetime
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEVICE_CLASS_DURATION,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_HOUR,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def _validate(config: dict[str, Any]) -> ConfigType:
|
||||
return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config})
|
||||
|
||||
|
||||
def test_max_lifetime_is_stored_in_minutes() -> None:
|
||||
config = _validate({"max_lifetime": "180d"})
|
||||
assert config["max_lifetime"].total_minutes == 180 * 24 * 60
|
||||
|
||||
|
||||
def test_max_lifetime_is_required() -> None:
|
||||
with pytest.raises(cv.Invalid, match="required key not provided"):
|
||||
_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0min", "30s", 6])
|
||||
def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
_validate({"max_lifetime": value})
|
||||
|
||||
|
||||
def test_defaults() -> None:
|
||||
config = _validate({"max_lifetime": "180d"})
|
||||
assert config[CONF_UPDATE_INTERVAL].total_seconds == 60
|
||||
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT
|
||||
assert config[CONF_ACCURACY_DECIMALS] == 2
|
||||
assert "is_on" not in config
|
||||
assert "current_speed" not in config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lambda_key", "sensor_key"),
|
||||
[("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")],
|
||||
)
|
||||
def test_lambda_and_sensor_are_mutually_exclusive(
|
||||
lambda_key: str, sensor_key: str
|
||||
) -> None:
|
||||
with pytest.raises(cv.Invalid, match="Cannot specify more than one of"):
|
||||
_validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
lambda_key: Lambda("return 1;"),
|
||||
sensor_key: "some_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_lambda_inputs_accepted() -> None:
|
||||
config = _validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
"is_on": Lambda("return true;"),
|
||||
"current_speed": Lambda("return 50.0f;"),
|
||||
}
|
||||
)
|
||||
assert config["is_on"].value == "return true;"
|
||||
assert config["current_speed"].value == "return 50.0f;"
|
||||
|
||||
|
||||
def test_sub_sensor_defaults() -> None:
|
||||
config = _validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
"runtime_hours": {"name": "Runtime"},
|
||||
"remaining_days": {"name": "Remaining"},
|
||||
}
|
||||
)
|
||||
runtime = config["runtime_hours"]
|
||||
assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR
|
||||
assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION
|
||||
assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
|
||||
remaining = config["remaining_days"]
|
||||
assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY
|
||||
assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION
|
||||
assert remaining[CONF_ACCURACY_DECIMALS] == 0
|
||||
Reference in New Issue
Block a user