From a77784a6da8ef60e065db96d769092c759faf6e4 Mon Sep 17 00:00:00 2001 From: Steve Baxter Date: Fri, 19 Mar 2021 08:16:27 +0000 Subject: [PATCH] Implement pulse_meter as an improvement on pulse_counter and pulse_width for meters (#1434) --- CODEOWNERS | 1 + esphome/components/pulse_meter/__init__.py | 0 .../pulse_meter/pulse_meter_sensor.cpp | 87 +++++++++++++++++++ .../pulse_meter/pulse_meter_sensor.h | 42 +++++++++ esphome/components/pulse_meter/sensor.py | 58 +++++++++++++ script/build_codeowners.py | 2 +- tests/test1.yaml | 7 ++ 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 esphome/components/pulse_meter/__init__.py create mode 100644 esphome/components/pulse_meter/pulse_meter_sensor.cpp create mode 100644 esphome/components/pulse_meter/pulse_meter_sensor.h create mode 100644 esphome/components/pulse_meter/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d6f90fbfe2..0a1f2b3ed2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -67,6 +67,7 @@ esphome/components/pn532/* @OttoWinter @jesserockz esphome/components/pn532_i2c/* @OttoWinter @jesserockz esphome/components/pn532_spi/* @OttoWinter @jesserockz esphome/components/power_supply/* @esphome/core +esphome/components/pulse_meter/* @stevebaxter esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet diff --git a/esphome/components/pulse_meter/__init__.py b/esphome/components/pulse_meter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp new file mode 100644 index 0000000000..8cf341c8a1 --- /dev/null +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -0,0 +1,87 @@ +#include "pulse_meter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace pulse_meter { + +static const char *TAG = "pulse_meter"; + +void PulseMeterSensor::setup() { + this->pin_->setup(); + this->isr_pin_ = pin_->to_isr(); + this->pin_->attach_interrupt(PulseMeterSensor::gpio_intr, this, CHANGE); + + this->last_detected_edge_us_ = 0; + this->last_valid_edge_us_ = 0; +} + +void PulseMeterSensor::loop() { + const uint32_t now = micros(); + + // If we've exceeded our timeout interval without receiving any pulses, assume 0 pulses/min until + // we get at least two valid pulses. + const uint32_t time_since_valid_edge_us = now - this->last_valid_edge_us_; + if ((this->last_valid_edge_us_ != 0) && (time_since_valid_edge_us > this->timeout_us_)) { + ESP_LOGD(TAG, "No pulse detected for %us, assuming 0 pulses/min", time_since_valid_edge_us / 1000000); + this->last_detected_edge_us_ = 0; + this->last_valid_edge_us_ = 0; + this->pulse_width_us_ = 0; + } + + // We quantize our pulse widths to 1 ms to avoid unnecessary jitter + const uint32_t pulse_width_ms = this->pulse_width_us_ / 1000; + if (this->pulse_width_dedupe_.next(pulse_width_ms)) { + if (pulse_width_ms == 0) { + // Treat 0 pulse width as 0 pulses/min (normally because we've not detected any pulses for a while) + this->publish_state(0); + } else { + // Calculate pulses/min from the pulse width in ms + this->publish_state((60.0 * 1000.0) / pulse_width_ms); + } + } + + if (this->total_sensor_ != nullptr) { + const uint32_t total = this->total_pulses_; + if (this->total_dedupe_.next(total)) { + this->total_sensor_->publish_state(total); + } + } +} + +void PulseMeterSensor::dump_config() { + LOG_SENSOR("", "Pulse Meter", this); + LOG_PIN(" Pin: ", this->pin_); + ESP_LOGCONFIG(TAG, " Filtering pulses shorter than %u µs", this->filter_us_); + ESP_LOGCONFIG(TAG, " Assuming 0 pulses/min after not receiving a pulse for %us", this->timeout_us_ / 1000000); +} + +void ICACHE_RAM_ATTR PulseMeterSensor::gpio_intr(PulseMeterSensor *sensor) { + // This is an interrupt handler - we can't call any virtual method from this method + + // Get the current time before we do anything else so the measurements are consistent + const uint32_t now = micros(); + + // We only look at rising edges + if (!sensor->isr_pin_->digital_read()) { + return; + } + + // Ignore the first detected pulse (we need at least two pulses to measure the width) + if (sensor->last_detected_edge_us_ != 0) { + // Check to see if we should filter this edge out + if ((now - sensor->last_detected_edge_us_) >= sensor->filter_us_) { + // Don't measure the first valid pulse (we need at least two pulses to measure the width) + if (sensor->last_valid_edge_us_ != 0) { + sensor->pulse_width_us_ = (now - sensor->last_valid_edge_us_); + } + + sensor->total_pulses_++; + sensor->last_valid_edge_us_ = now; + } + } + + sensor->last_detected_edge_us_ = now; +} + +} // namespace pulse_meter +} // namespace esphome diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h new file mode 100644 index 0000000000..7d3adbbbcb --- /dev/null +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/esphal.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace pulse_meter { + +class PulseMeterSensor : public sensor::Sensor, public Component { + public: + void set_pin(GPIOPin *pin) { this->pin_ = pin; } + void set_filter_us(uint32_t filter) { this->filter_us_ = filter; } + void set_timeout_us(uint32_t timeout) { this->timeout_us_ = timeout; } + void set_total_sensor(sensor::Sensor *sensor) { this->total_sensor_ = sensor; } + + void setup() override; + void loop() override; + float get_setup_priority() const override { return setup_priority::DATA; } + void dump_config() override; + + protected: + static void gpio_intr(PulseMeterSensor *sensor); + + GPIOPin *pin_ = nullptr; + ISRInternalGPIOPin *isr_pin_; + uint32_t filter_us_ = 0; + uint32_t timeout_us_ = 1000000UL * 60UL * 5UL; + sensor::Sensor *total_sensor_ = nullptr; + + Deduplicator pulse_width_dedupe_; + Deduplicator total_dedupe_; + + volatile uint32_t last_detected_edge_us_ = 0; + volatile uint32_t last_valid_edge_us_ = 0; + volatile uint32_t pulse_width_us_ = 0; + volatile uint32_t total_pulses_ = 0; +}; + +} // namespace pulse_meter +} // namespace esphome diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py new file mode 100644 index 0000000000..a97f276a96 --- /dev/null +++ b/esphome/components/pulse_meter/sensor.py @@ -0,0 +1,58 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components import sensor +from esphome.const import CONF_ID, CONF_INTERNAL_FILTER, \ + CONF_PIN, CONF_NUMBER, CONF_TIMEOUT, CONF_TOTAL, \ + ICON_PULSE, UNIT_PULSES, UNIT_PULSES_PER_MINUTE +from esphome.core import CORE + +CODEOWNERS = ['@stevebaxter'] + +pulse_meter_ns = cg.esphome_ns.namespace('pulse_meter') + +PulseMeterSensor = pulse_meter_ns.class_('PulseMeterSensor', + sensor.Sensor, + cg.Component) + + +def validate_internal_filter(value): + return cv.positive_time_period_microseconds(value) + + +def validate_timeout(value): + value = cv.positive_time_period_microseconds(value) + if value.total_minutes > 70: + raise cv.Invalid("Maximum timeout is 70 minutes") + return value + + +def validate_pulse_meter_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 + + +CONFIG_SCHEMA = sensor.sensor_schema(UNIT_PULSES_PER_MINUTE, ICON_PULSE, 2).extend({ + cv.GenerateID(): cv.declare_id(PulseMeterSensor), + cv.Required(CONF_PIN): validate_pulse_meter_pin, + cv.Optional(CONF_INTERNAL_FILTER, default='13us'): validate_internal_filter, + cv.Optional(CONF_TIMEOUT, default='5min'): validate_timeout, + cv.Optional(CONF_TOTAL): sensor.sensor_schema(UNIT_PULSES, ICON_PULSE, 0) +}) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield sensor.register_sensor(var, config) + + pin = yield cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) + cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) + + if CONF_TOTAL in config: + sens = yield sensor.new_sensor(config[CONF_TOTAL]) + cg.add(var.set_total_sensor(sens)) diff --git a/script/build_codeowners.py b/script/build_codeowners.py index a1e8d69046..2ee7521b91 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -36,7 +36,7 @@ esphome/core/* @esphome/core parts = [BASE] -# Fake some diretory so that get_component works +# Fake some directory so that get_component works CORE.config_path = str(root) codeowners = defaultdict(list) diff --git a/tests/test1.yaml b/tests/test1.yaml index a064bc0ed8..f5354231b4 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -626,6 +626,13 @@ sensor: falling_edge: DECREMENT internal_filter: 13us update_interval: 15s + - platform: pulse_meter + name: 'Pulse Meter' + pin: GPIO12 + internal_filter: 100ms + timeout: 2 min + total: + name: 'Pulse Meter Total' - platform: rotary_encoder name: 'Rotary Encoder' id: rotary_encoder1