diff --git a/esphome/components/pulse_counter/__init__.py b/esphome/components/pulse_counter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h new file mode 100644 index 0000000000..d749540a95 --- /dev/null +++ b/esphome/components/pulse_counter/automation.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/pulse_counter/pulse_counter_sensor.h" + +namespace esphome { + +namespace pulse_counter { + +template class SetTotalPulsesAction : public Action { + public: + SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} + + TEMPLATABLE_VALUE(uint32_t, total_pulses) + + void play(Ts... x) override { this->pulse_counter_->set_total_pulses(this->total_pulses_.value(x...)); } + + protected: + PulseCounterSensor *pulse_counter_; +}; + +} // namespace pulse_counter +} // namespace esphome diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp new file mode 100644 index 0000000000..281a61a66a --- /dev/null +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -0,0 +1,187 @@ +#include "pulse_counter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace pulse_counter { + +static const char *const TAG = "pulse_counter"; + +const char *const EDGE_MODE_TO_STRING[] = {"DISABLE", "INCREMENT", "DECREMENT"}; + +#ifdef HAS_PCNT +PulseCounterStorageBase *get_storage(bool hw_pcnt) { + return (hw_pcnt ? (PulseCounterStorageBase *) (new HwPulseCounterStorage) + : (PulseCounterStorageBase *) (new BasicPulseCounterStorage)); +} +#else +PulseCounterStorageBase *get_storage(bool) { return new BasicPulseCounterStorage; } +#endif + +void IRAM_ATTR BasicPulseCounterStorage::gpio_intr(BasicPulseCounterStorage *arg) { + const uint32_t now = micros(); + const bool discard = now - arg->last_pulse < arg->filter_us; + arg->last_pulse = now; + if (discard) + return; + + PulseCounterCountMode mode = arg->isr_pin.digital_read() ? arg->rising_edge_mode : arg->falling_edge_mode; + switch (mode) { + case PULSE_COUNTER_DISABLE: + break; + case PULSE_COUNTER_INCREMENT: + arg->counter++; + break; + case PULSE_COUNTER_DECREMENT: + arg->counter--; + break; + } +} +bool BasicPulseCounterStorage::pulse_counter_setup(InternalGPIOPin *pin) { + this->pin = pin; + this->pin->setup(); + this->isr_pin = this->pin->to_isr(); + this->pin->attach_interrupt(BasicPulseCounterStorage::gpio_intr, this, gpio::INTERRUPT_ANY_EDGE); + return true; +} +pulse_counter_t BasicPulseCounterStorage::read_raw_value() { + pulse_counter_t counter = this->counter; + pulse_counter_t ret = counter - this->last_value; + this->last_value = counter; + return ret; +} + +#ifdef HAS_PCNT +bool HwPulseCounterStorage::pulse_counter_setup(InternalGPIOPin *pin) { + static pcnt_unit_t next_pcnt_unit = PCNT_UNIT_0; + this->pin = pin; + this->pin->setup(); + this->pcnt_unit = next_pcnt_unit; + next_pcnt_unit = pcnt_unit_t(int(next_pcnt_unit) + 1); + + ESP_LOGCONFIG(TAG, " PCNT Unit Number: %u", this->pcnt_unit); + + pcnt_count_mode_t rising = PCNT_COUNT_DIS, falling = PCNT_COUNT_DIS; + switch (this->rising_edge_mode) { + case PULSE_COUNTER_DISABLE: + rising = PCNT_COUNT_DIS; + break; + case PULSE_COUNTER_INCREMENT: + rising = PCNT_COUNT_INC; + break; + case PULSE_COUNTER_DECREMENT: + rising = PCNT_COUNT_DEC; + break; + } + switch (this->falling_edge_mode) { + case PULSE_COUNTER_DISABLE: + falling = PCNT_COUNT_DIS; + break; + case PULSE_COUNTER_INCREMENT: + falling = PCNT_COUNT_INC; + break; + case PULSE_COUNTER_DECREMENT: + falling = PCNT_COUNT_DEC; + break; + } + + pcnt_config_t pcnt_config = { + .pulse_gpio_num = this->pin->get_pin(), + .ctrl_gpio_num = PCNT_PIN_NOT_USED, + .lctrl_mode = PCNT_MODE_KEEP, + .hctrl_mode = PCNT_MODE_KEEP, + .pos_mode = rising, + .neg_mode = falling, + .counter_h_lim = 0, + .counter_l_lim = 0, + .unit = this->pcnt_unit, + .channel = PCNT_CHANNEL_0, + }; + esp_err_t error = pcnt_unit_config(&pcnt_config); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Configuring Pulse Counter failed: %s", esp_err_to_name(error)); + return false; + } + + if (this->filter_us != 0) { + uint16_t filter_val = std::min(static_cast(this->filter_us * 80u), 1023u); + ESP_LOGCONFIG(TAG, " Filter Value: %" PRIu32 "us (val=%u)", this->filter_us, filter_val); + error = pcnt_set_filter_value(this->pcnt_unit, filter_val); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Setting filter value failed: %s", esp_err_to_name(error)); + return false; + } + error = pcnt_filter_enable(this->pcnt_unit); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Enabling filter failed: %s", esp_err_to_name(error)); + return false; + } + } + + error = pcnt_counter_pause(this->pcnt_unit); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Pausing pulse counter failed: %s", esp_err_to_name(error)); + return false; + } + error = pcnt_counter_clear(this->pcnt_unit); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Clearing pulse counter failed: %s", esp_err_to_name(error)); + return false; + } + error = pcnt_counter_resume(this->pcnt_unit); + if (error != ESP_OK) { + ESP_LOGE(TAG, "Resuming pulse counter failed: %s", esp_err_to_name(error)); + return false; + } + return true; +} +pulse_counter_t HwPulseCounterStorage::read_raw_value() { + pulse_counter_t counter; + pcnt_get_counter_value(this->pcnt_unit, &counter); + pulse_counter_t ret = counter - this->last_value; + this->last_value = counter; + return ret; +} +#endif + +void PulseCounterSensor::setup() { + ESP_LOGCONFIG(TAG, "Setting up pulse counter '%s'...", this->name_.c_str()); + if (!this->storage_.pulse_counter_setup(this->pin_)) { + this->mark_failed(); + return; + } +} + +void PulseCounterSensor::set_total_pulses(uint32_t pulses) { + this->current_total_ = pulses; + this->total_sensor_->publish_state(pulses); +} + +void PulseCounterSensor::dump_config() { + LOG_SENSOR("", "Pulse Counter", this); + LOG_PIN(" Pin: ", this->pin_); + ESP_LOGCONFIG(TAG, " Rising Edge: %s", EDGE_MODE_TO_STRING[this->storage_.rising_edge_mode]); + ESP_LOGCONFIG(TAG, " Falling Edge: %s", EDGE_MODE_TO_STRING[this->storage_.falling_edge_mode]); + ESP_LOGCONFIG(TAG, " Filtering pulses shorter than %" PRIu32 " µs", this->storage_.filter_us); + LOG_UPDATE_INTERVAL(this); +} + +void PulseCounterSensor::update() { + pulse_counter_t raw = this->storage_.read_raw_value(); + uint32_t now = millis(); + if (this->last_time_ != 0) { + uint32_t interval = now - this->last_time_; + float value = (60000.0f * raw) / float(interval); // per minute + ESP_LOGD(TAG, "'%s': Retrieved counter: %0.2f pulses/min", this->get_name().c_str(), value); + this->publish_state(value); + } + + if (this->total_sensor_ != nullptr) { + current_total_ += raw; + ESP_LOGD(TAG, "'%s': Total : %" PRIu32 " pulses", this->get_name().c_str(), current_total_); + this->total_sensor_->publish_state(current_total_); + } + this->last_time_ = now; +} + +} // namespace pulse_counter +} // namespace esphome diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h new file mode 100644 index 0000000000..ef9f73f95c --- /dev/null +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -0,0 +1,90 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/sensor/sensor.h" + +#include + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C3) +#include +#define HAS_PCNT +#endif + +namespace esphome { +namespace pulse_counter { + +enum PulseCounterCountMode { + PULSE_COUNTER_DISABLE = 0, + PULSE_COUNTER_INCREMENT, + PULSE_COUNTER_DECREMENT, +}; + +#ifdef HAS_PCNT +using pulse_counter_t = int16_t; +#else +using pulse_counter_t = int32_t; +#endif + +struct PulseCounterStorageBase { + virtual bool pulse_counter_setup(InternalGPIOPin *pin) = 0; + virtual pulse_counter_t read_raw_value() = 0; + + InternalGPIOPin *pin; + PulseCounterCountMode rising_edge_mode{PULSE_COUNTER_INCREMENT}; + PulseCounterCountMode falling_edge_mode{PULSE_COUNTER_DISABLE}; + uint32_t filter_us{0}; + pulse_counter_t last_value{0}; +}; + +struct BasicPulseCounterStorage : public PulseCounterStorageBase { + static void gpio_intr(BasicPulseCounterStorage *arg); + + bool pulse_counter_setup(InternalGPIOPin *pin) override; + pulse_counter_t read_raw_value() override; + + volatile pulse_counter_t counter{0}; + volatile uint32_t last_pulse{0}; + + ISRInternalGPIOPin isr_pin; +}; + +#ifdef HAS_PCNT +struct HwPulseCounterStorage : public PulseCounterStorageBase { + bool pulse_counter_setup(InternalGPIOPin *pin) override; + pulse_counter_t read_raw_value() override; + + pcnt_unit_t pcnt_unit; +}; +#endif + +PulseCounterStorageBase *get_storage(bool hw_pcnt = false); + +class PulseCounterSensor : public sensor::Sensor, public PollingComponent { + public: + explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} + + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } + void set_rising_edge_mode(PulseCounterCountMode mode) { storage_.rising_edge_mode = mode; } + void set_falling_edge_mode(PulseCounterCountMode mode) { storage_.falling_edge_mode = mode; } + void set_filter_us(uint32_t filter) { storage_.filter_us = filter; } + void set_total_sensor(sensor::Sensor *total_sensor) { total_sensor_ = total_sensor; } + + void set_total_pulses(uint32_t pulses); + + /// Unit of measurement is "pulses/min". + void setup() override; + void update() override; + float get_setup_priority() const override { return setup_priority::DATA; } + void dump_config() override; + + protected: + InternalGPIOPin *pin_; + PulseCounterStorageBase &storage_; + uint32_t last_time_{0}; + uint32_t current_total_{0}; + sensor::Sensor *total_sensor_{nullptr}; +}; + +} // namespace pulse_counter +} // namespace esphome diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py new file mode 100644 index 0000000000..27364a34b3 --- /dev/null +++ b/esphome/components/pulse_counter/sensor.py @@ -0,0 +1,156 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation, pins +from esphome.components import sensor +from esphome.const import ( + CONF_COUNT_MODE, + CONF_FALLING_EDGE, + CONF_ID, + CONF_INTERNAL_FILTER, + CONF_PIN, + CONF_RISING_EDGE, + CONF_NUMBER, + CONF_TOTAL, + CONF_VALUE, + ICON_PULSE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_PULSES_PER_MINUTE, + UNIT_PULSES, +) +from esphome.core import CORE + +CONF_USE_PCNT = "use_pcnt" + +pulse_counter_ns = cg.esphome_ns.namespace("pulse_counter") +PulseCounterCountMode = pulse_counter_ns.enum("PulseCounterCountMode") +COUNT_MODES = { + "DISABLE": PulseCounterCountMode.PULSE_COUNTER_DISABLE, + "INCREMENT": PulseCounterCountMode.PULSE_COUNTER_INCREMENT, + "DECREMENT": PulseCounterCountMode.PULSE_COUNTER_DECREMENT, +} + +COUNT_MODE_SCHEMA = cv.enum(COUNT_MODES, upper=True) + +PulseCounterSensor = pulse_counter_ns.class_( + "PulseCounterSensor", sensor.Sensor, cg.PollingComponent +) + +SetTotalPulsesAction = pulse_counter_ns.class_( + "SetTotalPulsesAction", automation.Action +) + + +def validate_internal_filter(value): + use_pcnt = value.get(CONF_USE_PCNT) + if CORE.is_esp8266 and use_pcnt: + raise cv.Invalid( + "Using hardware PCNT is only available on ESP32", + [CONF_USE_PCNT], + ) + + if CORE.is_esp32 and use_pcnt: + if value.get(CONF_INTERNAL_FILTER).total_microseconds > 13: + raise cv.Invalid( + "Maximum internal filter value when using ESP32 hardware PCNT is 13us", + [CONF_INTERNAL_FILTER], + ) + + return value + + +def validate_pulse_counter_pin(value): + value = pins.internal_gpio_input_pin_schema(value) + if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: + raise cv.Invalid( + "Pins GPIO16 and GPIO17 cannot be used as pulse counters on ESP8266." + ) + return value + + +def validate_count_mode(value): + rising_edge = value[CONF_RISING_EDGE] + falling_edge = value[CONF_FALLING_EDGE] + if rising_edge == "DISABLE" and falling_edge == "DISABLE": + raise cv.Invalid( + "Can't set both count modes to DISABLE! This means no counting occurs at " + "all!" + ) + return value + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + PulseCounterSensor, + unit_of_measurement=UNIT_PULSES_PER_MINUTE, + icon=ICON_PULSE, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.Required(CONF_PIN): validate_pulse_counter_pin, + cv.Optional( + CONF_COUNT_MODE, + default={ + CONF_RISING_EDGE: "INCREMENT", + CONF_FALLING_EDGE: "DISABLE", + }, + ): cv.All( + cv.Schema( + { + cv.Required(CONF_RISING_EDGE): COUNT_MODE_SCHEMA, + cv.Required(CONF_FALLING_EDGE): COUNT_MODE_SCHEMA, + } + ), + validate_count_mode, + ), + cv.SplitDefault(CONF_USE_PCNT, esp32=True): cv.boolean, + cv.Optional( + CONF_INTERNAL_FILTER, default="13us" + ): cv.positive_time_period_microseconds, + cv.Optional(CONF_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_PULSES, + icon=ICON_PULSE, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + }, + ) + .extend(cv.polling_component_schema("60s")), + validate_internal_filter, +) + + +async def to_code(config): + var = await sensor.new_sensor(config, config.get(CONF_USE_PCNT)) + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + count = config[CONF_COUNT_MODE] + cg.add(var.set_rising_edge_mode(count[CONF_RISING_EDGE])) + cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) + cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) + + if CONF_TOTAL in config: + sens = await sensor.new_sensor(config[CONF_TOTAL]) + cg.add(var.set_total_sensor(sens)) + + +@automation.register_action( + "pulse_counter.set_total_pulses", + SetTotalPulsesAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(PulseCounterSensor), + cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), + } + ), +) +async def set_total_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_VALUE], args, int) + cg.add(var.set_total_pulses(template_)) + return var