[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:
Tobias Stanzel
2026-10-13 07:52:46 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent c1c1d2b2ae
commit 4a80d785a8
15 changed files with 672 additions and 0 deletions
+1
View File
@@ -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