mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
[counter] Add persistent counter sensor (#19746)
This commit is contained in:
@@ -125,6 +125,7 @@ esphome/components/combination/* @Cat-Ion @kahrendt
|
||||
esphome/components/const/* @esphome/core
|
||||
esphome/components/coolix/* @glmnet
|
||||
esphome/components/copy/* @OttoWinter
|
||||
esphome/components/counter/* @clydebarrow
|
||||
esphome/components/cover/* @esphome/core
|
||||
esphome/components/cs5460a/* @balrog-kun
|
||||
esphome/components/cse7761/* @berfenger
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
@@ -0,0 +1,28 @@
|
||||
#include "counter_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::counter {
|
||||
|
||||
static const char *const TAG = "counter";
|
||||
|
||||
void CounterSensor::setup() {
|
||||
if (this->restore_) {
|
||||
this->pref_ = this->make_entity_preference<int64_t>();
|
||||
this->pref_.load(&this->value_);
|
||||
}
|
||||
this->publish_state(static_cast<float>(this->value_));
|
||||
}
|
||||
|
||||
void CounterSensor::set_value(int64_t value) {
|
||||
this->value_ = value;
|
||||
this->publish_state(static_cast<float>(value));
|
||||
if (this->restore_)
|
||||
this->pref_.save(&this->value_);
|
||||
}
|
||||
|
||||
void CounterSensor::dump_config() {
|
||||
LOG_SENSOR("", "Counter", this);
|
||||
ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_));
|
||||
}
|
||||
|
||||
} // namespace esphome::counter
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::counter {
|
||||
|
||||
/// A counter held as a 64-bit integer. The published sensor state is a float, so it is exact only up to 2^24.
|
||||
class CounterSensor final : public sensor::Sensor, public Component {
|
||||
public:
|
||||
/// The counter starts at initial_value unless a stored value is restored.
|
||||
CounterSensor(bool restore, int64_t initial_value) : value_(initial_value), restore_(restore) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
// restore value before `on_boot` automations run
|
||||
float get_setup_priority() const override { return setup_priority::DATA + 50.0f; }
|
||||
|
||||
/// Increment by one each time the given sensor publishes a state.
|
||||
void count_updates_from(sensor::Sensor *source) {
|
||||
source->add_on_state_callback([this](float) { this->increment(); });
|
||||
}
|
||||
|
||||
/// Increment by one each time the given binary sensor changes to true.
|
||||
template<typename T> void count_true_from(T *source) {
|
||||
source->add_on_state_callback([this](bool state) {
|
||||
if (state)
|
||||
this->increment();
|
||||
});
|
||||
}
|
||||
|
||||
void set_value(int64_t value);
|
||||
/// overflow on addition of signed numbers is undefined - use the well defined unsigned version
|
||||
void increment(int64_t amount = 1) {
|
||||
this->set_value(static_cast<int64_t>(static_cast<uint64_t>(this->value_) + static_cast<uint64_t>(amount)));
|
||||
}
|
||||
|
||||
protected:
|
||||
ESPPreferenceObject pref_;
|
||||
int64_t value_;
|
||||
bool restore_;
|
||||
};
|
||||
|
||||
} // namespace esphome::counter
|
||||
@@ -0,0 +1,85 @@
|
||||
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 (
|
||||
CONF_BINARY_SENSOR,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_VALUE,
|
||||
CONF_RESTORE,
|
||||
CONF_SENSOR,
|
||||
CONF_VALUE,
|
||||
ICON_COUNTER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
counter_ns = cg.esphome_ns.namespace("counter")
|
||||
CounterSensor = counter_ns.class_("CounterSensor", sensor.Sensor, cg.Component)
|
||||
|
||||
# The lowest value is left out because its C++ literal cannot be written portably.
|
||||
INT64_MAX = 2**63 - 1
|
||||
COUNTER_RANGE = cv.int_range(min=-INT64_MAX, max=INT64_MAX)
|
||||
COUNTER_VALUE = cv.templatable(COUNTER_RANGE)
|
||||
|
||||
|
||||
def _not_own_source(config: ConfigType) -> ConfigType:
|
||||
if (source := config.get(CONF_SENSOR)) is not None and source.id == config[
|
||||
CONF_ID
|
||||
].id:
|
||||
raise cv.Invalid("A counter cannot count its own updates", [CONF_SENSOR])
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
CounterSensor,
|
||||
icon=ICON_COUNTER,
|
||||
accuracy_decimals=0,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_RESTORE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_INITIAL_VALUE, default=0): COUNTER_RANGE,
|
||||
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_not_own_source,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID], config[CONF_RESTORE], config[CONF_INITIAL_VALUE]
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
await sensor.register_sensor(var, config)
|
||||
if (source := config.get(CONF_SENSOR)) is not None:
|
||||
cg.add(var.count_updates_from(await cg.get_variable(source)))
|
||||
if (source := config.get(CONF_BINARY_SENSOR)) is not None:
|
||||
cg.add(var.count_true_from(await cg.get_variable(source)))
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"counter.set_value",
|
||||
cv.maybe_simple_value(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(CounterSensor),
|
||||
cv.Required(CONF_VALUE): COUNTER_VALUE,
|
||||
},
|
||||
key=CONF_VALUE,
|
||||
),
|
||||
automation.ApplyField(CONF_VALUE, "set_value", cg.int64),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"counter.increment",
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(CounterSensor),
|
||||
cv.Optional(CONF_VALUE, default=1): COUNTER_VALUE,
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_VALUE, "increment", cg.int64),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
- counter.set_value: 5
|
||||
- counter.set_value:
|
||||
value: 7
|
||||
- counter.increment:
|
||||
value: -3
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
- platform: counter
|
||||
id: only_counter
|
||||
name: Only Counter
|
||||
@@ -0,0 +1,42 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
- counter.set_value:
|
||||
id: counter_a
|
||||
value: 100
|
||||
- counter.set_value:
|
||||
id: counter_b
|
||||
value: -5000000000
|
||||
- counter.increment: counter_a
|
||||
- counter.increment:
|
||||
id: counter_a
|
||||
value: -7
|
||||
- counter.increment:
|
||||
id: counter_b
|
||||
value: !lambda "return 3;"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: source_sensor
|
||||
lambda: return 1.0;
|
||||
- platform: counter
|
||||
id: counter_a
|
||||
name: Counter A
|
||||
sensor: source_sensor
|
||||
- platform: counter
|
||||
id: counter_b
|
||||
name: Counter B
|
||||
restore: false
|
||||
initial_value: -5000000000
|
||||
- platform: counter
|
||||
id: counter_c
|
||||
name: Counter C
|
||||
binary_sensor: source_binary_sensor
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: source_binary_sensor
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for the counter sensor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE
|
||||
|
||||
INT64_MAX = 2**63 - 1
|
||||
|
||||
|
||||
def test_counter_constructor_arguments(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Restore (on by default) and initial value (zero by default) are constructor arguments."""
|
||||
main_cpp = generate_main(component_config_path("counter_test.yaml"))
|
||||
|
||||
assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp
|
||||
assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp
|
||||
|
||||
|
||||
def test_counter_sensor_option_registers_source(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Only the counter with a sensor option counts that sensor's updates."""
|
||||
main_cpp = generate_main(component_config_path("counter_test.yaml"))
|
||||
|
||||
assert "counter_a->count_updates_from(source_sensor);" in main_cpp
|
||||
assert main_cpp.count("count_updates_from") == 1
|
||||
|
||||
|
||||
def test_counter_binary_sensor_option_registers_source(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Only the counter with a binary_sensor option counts that sensor's changes to true."""
|
||||
main_cpp = generate_main(component_config_path("counter_test.yaml"))
|
||||
|
||||
assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp
|
||||
assert main_cpp.count("count_true_from") == 1
|
||||
|
||||
|
||||
def test_counter_actions(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Values reach the counter as 64-bit integers; increment defaults to one."""
|
||||
main_cpp = generate_main(component_config_path("counter_test.yaml"))
|
||||
|
||||
assert "::counter_a->set_value(100);" in main_cpp
|
||||
assert "::counter_b->set_value(-5000000000LL);" in main_cpp
|
||||
assert "::counter_a->increment(1);" in main_cpp
|
||||
assert "::counter_a->increment(-7);" in main_cpp
|
||||
assert "::counter_b->increment(static_cast<int64_t>(3));" in main_cpp
|
||||
|
||||
|
||||
def test_counter_actions_without_id_use_only_counter(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""With one counter configured, the id may be left out of an action."""
|
||||
main_cpp = generate_main(component_config_path("counter_single.yaml"))
|
||||
|
||||
assert "::only_counter->set_value(5);" in main_cpp
|
||||
assert "::only_counter->set_value(7);" in main_cpp
|
||||
assert "::only_counter->increment(-3);" in main_cpp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
|
||||
def test_counter_initial_value_must_be_int64(value: float) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value})
|
||||
|
||||
|
||||
def test_counter_cannot_count_itself() -> None:
|
||||
"""A counter watching its own updates would recurse forever."""
|
||||
with pytest.raises(cv.Invalid, match="cannot count its own updates"):
|
||||
CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"})
|
||||
|
||||
|
||||
def test_counter_accepts_other_source() -> None:
|
||||
config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"})
|
||||
|
||||
assert config["sensor"].id == "other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX])
|
||||
def test_counter_value_accepts_int64_range(value: int) -> None:
|
||||
assert COUNTER_VALUE(value) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5])
|
||||
def test_counter_value_rejects_out_of_range(value: float) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
COUNTER_VALUE(value)
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/counter/counter_sensor.h"
|
||||
|
||||
namespace esphome::counter::testing {
|
||||
|
||||
constexpr int64_t INT64_MAX_VALUE = std::numeric_limits<int64_t>::max();
|
||||
constexpr int64_t INT64_MIN_VALUE = std::numeric_limits<int64_t>::min();
|
||||
|
||||
/// Stands in for a binary sensor: reports each state to its callbacks.
|
||||
struct FakeBinarySource {
|
||||
template<typename F> void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward<F>(callback)); }
|
||||
void publish(bool state) { this->callbacks_.call(state); }
|
||||
CallbackManager<void(bool)> callbacks_;
|
||||
};
|
||||
|
||||
// Restore is off so no preference storage is needed.
|
||||
class CounterTest : public ::testing::Test {
|
||||
protected:
|
||||
CounterSensor counter_{false, 0};
|
||||
};
|
||||
|
||||
} // namespace esphome::counter::testing
|
||||
@@ -0,0 +1,37 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- counter.set_value:
|
||||
id: counter_sensor
|
||||
value: 100
|
||||
- counter.increment: counter_sensor
|
||||
- counter.increment:
|
||||
id: counter_sensor
|
||||
value: -5
|
||||
- counter.increment:
|
||||
id: counter_sensor
|
||||
value: !lambda "return 5000000000LL;"
|
||||
|
||||
sensor:
|
||||
- platform: counter
|
||||
id: counter_sensor
|
||||
name: Counter
|
||||
- platform: counter
|
||||
id: counter_no_restore
|
||||
name: Counter No Restore
|
||||
restore: false
|
||||
initial_value: 10
|
||||
- platform: template
|
||||
id: source_sensor
|
||||
lambda: return 1.0;
|
||||
update_interval: 1s
|
||||
- platform: counter
|
||||
name: Counter Of Sensor
|
||||
sensor: source_sensor
|
||||
- platform: counter
|
||||
name: Counter Of Binary Sensor
|
||||
binary_sensor: source_binary_sensor
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: source_binary_sensor
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "../common.h"
|
||||
|
||||
namespace esphome::counter::testing {
|
||||
|
||||
TEST_F(CounterTest, StartsAtZero) {
|
||||
this->counter_.setup();
|
||||
EXPECT_EQ(this->counter_.state, 0.0f);
|
||||
}
|
||||
|
||||
TEST(CounterInitialValue, PublishedAtSetupWhenNotRestoring) {
|
||||
CounterSensor counter(false, -12);
|
||||
counter.setup();
|
||||
EXPECT_EQ(counter.state, -12.0f);
|
||||
counter.increment();
|
||||
EXPECT_EQ(counter.state, -11.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, SetValuePublishesState) {
|
||||
this->counter_.set_value(42);
|
||||
EXPECT_EQ(this->counter_.state, 42.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, IncrementDefaultsToOne) {
|
||||
this->counter_.increment();
|
||||
this->counter_.increment();
|
||||
EXPECT_EQ(this->counter_.state, 2.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, IncrementAcceptsNegativeAmounts) {
|
||||
this->counter_.set_value(10);
|
||||
this->counter_.increment(-25);
|
||||
EXPECT_EQ(this->counter_.state, -15.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, ValueBeyondInt32) {
|
||||
this->counter_.set_value(5000000000LL);
|
||||
this->counter_.increment(5000000000LL);
|
||||
EXPECT_EQ(this->counter_.state, 1.0e10f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, IncrementWrapsAtInt64Limits) {
|
||||
this->counter_.set_value(INT64_MAX_VALUE);
|
||||
this->counter_.increment(1);
|
||||
EXPECT_EQ(this->counter_.state, static_cast<float>(INT64_MIN_VALUE));
|
||||
|
||||
this->counter_.set_value(INT64_MIN_VALUE);
|
||||
this->counter_.increment(-1);
|
||||
EXPECT_EQ(this->counter_.state, static_cast<float>(INT64_MAX_VALUE));
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, CountsEachPublishFromSource) {
|
||||
sensor::Sensor source;
|
||||
this->counter_.count_updates_from(&source);
|
||||
|
||||
// The counted value is unrelated to what the source publishes.
|
||||
source.publish_state(10.0f);
|
||||
source.publish_state(10.0f);
|
||||
source.publish_state(-3.5f);
|
||||
EXPECT_EQ(this->counter_.state, 3.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, SourceUpdatesAddToCurrentValue) {
|
||||
sensor::Sensor source;
|
||||
this->counter_.count_updates_from(&source);
|
||||
|
||||
this->counter_.set_value(100);
|
||||
source.publish_state(1.0f);
|
||||
EXPECT_EQ(this->counter_.state, 101.0f);
|
||||
}
|
||||
|
||||
TEST_F(CounterTest, CountsOnlyTrueFromBinarySource) {
|
||||
FakeBinarySource source;
|
||||
this->counter_.count_true_from(&source);
|
||||
|
||||
source.publish(true);
|
||||
source.publish(false);
|
||||
EXPECT_EQ(this->counter_.state, 1.0f);
|
||||
source.publish(true);
|
||||
source.publish(false);
|
||||
source.publish(false);
|
||||
EXPECT_EQ(this->counter_.state, 2.0f);
|
||||
}
|
||||
|
||||
} // namespace esphome::counter::testing
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
counter: !include common.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
packages:
|
||||
counter: !include common.yaml
|
||||
|
||||
esphome:
|
||||
on_shutdown:
|
||||
then:
|
||||
- counter.set_value:
|
||||
id: counter_no_restore
|
||||
value: -9223372036854775807
|
||||
- counter.increment:
|
||||
id: counter_no_restore
|
||||
value: 9223372036854775807
|
||||
Reference in New Issue
Block a user