diff --git a/CODEOWNERS b/CODEOWNERS index fc14cb33d09..2c026b92f5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/filter_lifetime/__init__.py b/esphome/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..99a67538bf2 --- /dev/null +++ b/esphome/components/filter_lifetime/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tuct"] +DOMAIN = "filter_lifetime" diff --git a/esphome/components/filter_lifetime/filter_lifetime.cpp b/esphome/components/filter_lifetime/filter_lifetime.cpp new file mode 100644 index 00000000000..47059a7f7a2 --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.cpp @@ -0,0 +1,112 @@ +#include "filter_lifetime.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +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(); + 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(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 diff --git a/esphome/components/filter_lifetime/filter_lifetime.h b/esphome/components/filter_lifetime/filter_lifetime.h new file mode 100644 index 00000000000..e6f40d78f5b --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.h @@ -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 diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py new file mode 100644 index 00000000000..58af033f329 --- /dev/null +++ b/esphome/components/filter_lifetime/sensor.py @@ -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()"), +) diff --git a/tests/components/filter_lifetime/__init__.py b/tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..70b7f08adf3 --- /dev/null +++ b/tests/components/filter_lifetime/__init__.py @@ -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"] diff --git a/tests/components/filter_lifetime/common.yaml b/tests/components/filter_lifetime/common.yaml new file mode 100644 index 00000000000..1cf8a3e0b18 --- /dev/null +++ b/tests/components/filter_lifetime/common.yaml @@ -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 diff --git a/tests/components/filter_lifetime/lambda_only.yaml b/tests/components/filter_lifetime/lambda_only.yaml new file mode 100644 index 00000000000..9affd2d469f --- /dev/null +++ b/tests/components/filter_lifetime/lambda_only.yaml @@ -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" diff --git a/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml new file mode 100644 index 00000000000..aa0d3c679ff --- /dev/null +++ b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include lambda_only.yaml diff --git a/tests/components/filter_lifetime/test.esp32-idf.yaml b/tests/components/filter_lifetime/test.esp32-idf.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.esp8266-ard.yaml b/tests/components/filter_lifetime/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.rp2040-ard.yaml b/tests/components/filter_lifetime/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp new file mode 100644 index 00000000000..a4c89edd856 --- /dev/null +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -0,0 +1,224 @@ +#ifdef USE_HOST + +#include + +#include +#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().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 diff --git a/tests/unit_tests/components/filter_lifetime/__init__.py b/tests/unit_tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/filter_lifetime/test_sensor.py b/tests/unit_tests/components/filter_lifetime/test_sensor.py new file mode 100644 index 00000000000..7e8d85fcda6 --- /dev/null +++ b/tests/unit_tests/components/filter_lifetime/test_sensor.py @@ -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