From b8af6cb005d1195d8a7acca4e1ad7bc7ebc05096 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:25:54 +1000 Subject: [PATCH] [counter] Add persistent counter sensor (#19746) --- CODEOWNERS | 1 + esphome/components/counter/__init__.py | 1 + esphome/components/counter/counter_sensor.cpp | 28 ++++++ esphome/components/counter/counter_sensor.h | 47 +++++++++ esphome/components/counter/sensor.py | 85 ++++++++++++++++ tests/component_tests/counter/__init__.py | 0 .../counter/config/counter_single.yaml | 17 ++++ .../counter/config/counter_test.yaml | 42 ++++++++ tests/component_tests/counter/test_counter.py | 99 +++++++++++++++++++ tests/components/counter/common.h | 30 ++++++ tests/components/counter/common.yaml | 37 +++++++ .../counter/sensor/test_counter.cpp | 84 ++++++++++++++++ tests/components/counter/test.esp32-idf.yaml | 2 + .../counter/validate.esp32-idf.yaml | 12 +++ 14 files changed, 485 insertions(+) create mode 100644 esphome/components/counter/__init__.py create mode 100644 esphome/components/counter/counter_sensor.cpp create mode 100644 esphome/components/counter/counter_sensor.h create mode 100644 esphome/components/counter/sensor.py create mode 100644 tests/component_tests/counter/__init__.py create mode 100644 tests/component_tests/counter/config/counter_single.yaml create mode 100644 tests/component_tests/counter/config/counter_test.yaml create mode 100644 tests/component_tests/counter/test_counter.py create mode 100644 tests/components/counter/common.h create mode 100644 tests/components/counter/common.yaml create mode 100644 tests/components/counter/sensor/test_counter.cpp create mode 100644 tests/components/counter/test.esp32-idf.yaml create mode 100644 tests/components/counter/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index eaf8c79576..ff2b6eb327 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/counter/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp new file mode 100644 index 0000000000..22adb49cca --- /dev/null +++ b/esphome/components/counter/counter_sensor.cpp @@ -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(); + this->pref_.load(&this->value_); + } + this->publish_state(static_cast(this->value_)); +} + +void CounterSensor::set_value(int64_t value) { + this->value_ = value; + this->publish_state(static_cast(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 diff --git a/esphome/components/counter/counter_sensor.h b/esphome/components/counter/counter_sensor.h new file mode 100644 index 0000000000..3468c4d5a0 --- /dev/null +++ b/esphome/components/counter/counter_sensor.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#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 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(static_cast(this->value_) + static_cast(amount))); + } + + protected: + ESPPreferenceObject pref_; + int64_t value_; + bool restore_; +}; + +} // namespace esphome::counter diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py new file mode 100644 index 0000000000..0f36b1721b --- /dev/null +++ b/esphome/components/counter/sensor.py @@ -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), +) diff --git a/tests/component_tests/counter/__init__.py b/tests/component_tests/counter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/counter/config/counter_single.yaml b/tests/component_tests/counter/config/counter_single.yaml new file mode 100644 index 0000000000..41b7f55233 --- /dev/null +++ b/tests/component_tests/counter/config/counter_single.yaml @@ -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 diff --git a/tests/component_tests/counter/config/counter_test.yaml b/tests/component_tests/counter/config/counter_test.yaml new file mode 100644 index 0000000000..b149df42b7 --- /dev/null +++ b/tests/component_tests/counter/config/counter_test.yaml @@ -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 diff --git a/tests/component_tests/counter/test_counter.py b/tests/component_tests/counter/test_counter.py new file mode 100644 index 0000000000..454f023af1 --- /dev/null +++ b/tests/component_tests/counter/test_counter.py @@ -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(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) diff --git a/tests/components/counter/common.h b/tests/components/counter/common.h new file mode 100644 index 0000000000..ac9cf7f5a9 --- /dev/null +++ b/tests/components/counter/common.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include +#include + +#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::max(); +constexpr int64_t INT64_MIN_VALUE = std::numeric_limits::min(); + +/// Stands in for a binary sensor: reports each state to its callbacks. +struct FakeBinarySource { + template void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward(callback)); } + void publish(bool state) { this->callbacks_.call(state); } + CallbackManager callbacks_; +}; + +// Restore is off so no preference storage is needed. +class CounterTest : public ::testing::Test { + protected: + CounterSensor counter_{false, 0}; +}; + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/common.yaml b/tests/components/counter/common.yaml new file mode 100644 index 0000000000..6a0d8aecc8 --- /dev/null +++ b/tests/components/counter/common.yaml @@ -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 diff --git a/tests/components/counter/sensor/test_counter.cpp b/tests/components/counter/sensor/test_counter.cpp new file mode 100644 index 0000000000..a0431062e8 --- /dev/null +++ b/tests/components/counter/sensor/test_counter.cpp @@ -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(INT64_MIN_VALUE)); + + this->counter_.set_value(INT64_MIN_VALUE); + this->counter_.increment(-1); + EXPECT_EQ(this->counter_.state, static_cast(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 diff --git a/tests/components/counter/test.esp32-idf.yaml b/tests/components/counter/test.esp32-idf.yaml new file mode 100644 index 0000000000..cc0d0d4e7f --- /dev/null +++ b/tests/components/counter/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + counter: !include common.yaml diff --git a/tests/components/counter/validate.esp32-idf.yaml b/tests/components/counter/validate.esp32-idf.yaml new file mode 100644 index 0000000000..22751677af --- /dev/null +++ b/tests/components/counter/validate.esp32-idf.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