---
title: "ESP32 Pulse Counter with NORVI X-DI4 | Flow & Production"
description: "## Introduction An ESP32 pulse counter turns a stream of on/off edges into numbers you can act on: litres per minute, litres transferred, parts produced. This tutorial builds one with the **NORVI X-CPU-ESPS3-X1** controller and the **NORVI X-DI4** expansion module.…"
url: "https://norvi.io/docs/esp32-pulse-counter-x-di4/"
updated: "2026-10-16"
category: NORVI X / NORVI X TUTORIALS
---

# ESP32 Pulse Counter with NORVI X-DI4 | Flow & Production

## Introduction

An ESP32 pulse counter turns a stream of on/off edges into numbers you can act on: litres per minute, litres transferred, parts produced. This tutorial builds one with the **NORVI X-CPU-ESPS3-X1** controller and the **NORVI X-DI4** expansion module. You count pulses from a turbine flow meter on IN1 and from a production sensor on IN2, filter noise with debounce, and keep totals correct for months with overflow-safe counters.

The X-DI4 fits this job because it wires its four optically isolated 24 V inputs straight to ESP32-S3 GPIO pins instead of routing them through an I²C port expander. It switches at up to 5 kHz in fast input mode, so an interrupt-driven ESP32 counter input captures every pulse a flow meter produces.

### What you will need

- NORVI X-CPU-ESPS3-X1 controller and NORVI X-DI4 expansion module
- Regulated 24 V DC supply (1 A or higher), USB cable, and the Arduino IDE with ESP32 support
- Turbine flow meter with pulse output: the High Accuracy Diesel Milk Pure Water Turbine Flow Sensor, **K = 820 pulses per litre**
- 24 V PNP production sensor (proximity or photoelectric) that gives one pulse per part

## System Architecture

Both sensors connect to the X-DI4, which isolates each signal and passes it to the ESP32-S3 through the expansion port. The firmware counts edges in interrupts and calculates flow rate and totals once per second.

![Figure 1: System architecture of the ESP32 pulse counter](https://norvi.io/wp-content/uploads/2026/10/System-architecture-of-the-ESP32-pulse-counter.png)
Figure 1: System architecture of the ESP32 pulse counter

## Hardware and Wiring

### X-DI4 input specifications

The ESP32-S3 GPIO pins run at 3.3 V and cannot take 24 V directly. The X-DI4 solves the ESP32 24V input problem: an optocoupler on each channel isolates the field signal and presents clean logic to the controller.

| **Parameter** | **Value** | **Why it matters** |
| --- | --- | --- |
| Inputs | 4, isolated, sink or source | IN1 to IN4 share one DCOM |
| Input voltage range | 0 to 30 V DC | Direct 24 V sensor signals |
| Logic high / low | above 15 V / below 5 V | 24 V pulses switch cleanly |
| Input current | about 7 mA at 24 V | Sensor must drive this load |
| Switching frequency | up to 5 kHz | Caps flow at 365.9 L/min for K = 820 |
| Isolation | above 1500 Vrms, per channel | Protects the ESP32-S3 |
| GPIO map | IN1 IO5, IN2 IO6, IN3 IO7, IN4 IO10 | Pin numbers for firmware |

### Connect the module and the sensors

1. Power off the system.
2. Mount the X-DI4 on the DIN rail beside the CPU and seat it on the expansion bus connector.
3. Wire the field devices as the table below shows.
4. Power the system back on.

| **Device wire** | **Flow meter (IN1)** | **Production sensor (IN2)** |
| --- | --- | --- |
| Positive supply | Red: +24 V | +24 V |
| Signal | Yellow: IN1 | Signal: IN2 |
| Negative / 0 V | Blue: DCOM and supply 0 V | 0 V: DCOM and supply 0 V |

![Figure 2: Wiring for the flow meter (IN1) and the production sensor (IN2)](https://norvi.io/wp-content/uploads/2026/10/Wiring-for-the-flow-meter-IN1-and-the-production-sensor-IN2.png)
Figure 2: Wiring for the flow meter (IN1) and the production sensor (IN2)

The X-DI4 accepts both sinking and sourcing sensors. This tutorial wires PNP (sourcing) sensors: connect each signal to its INx terminal and the sensor negative lead to DCOM. All four inputs share DCOM. Power the flow meter from the same 24 V supply as the controller, so its pulse output swings to 24 V and clears the 15 V logic-high threshold.

**Pin conflict.**The X-DI4 claims IO5, IO6, IO7 and IO10. You cannot combine it with the X-UART-IO module, which uses the same pins.

## How the Pulse Counter Calculates Flow

A turbine meter emits a fixed number of pulses per litre, called the K factor. Count the pulses in a known window and you get frequency, flow rate and volume:

- Pulse frequency (Hz) = pulses in window / window time (s)
- Flow (L/s) = frequency / K, and Flow (L/min) = Flow (L/s) × 60
- Total volume (L) = total pulses / K

With K = 820, one pulse equals 1.22 mL. A reading of 723 Hz gives 723 / 820 × 60 = **52.9 L/min**, the same value the EC-M12 flow dashboard shows for 723 Hz. The production counter needs no K factor: each debounced edge equals one part, and parts per minute equals pulses in the window × 60 / window seconds.

The table shows the pulse frequency the X-DI4 sees at typical flow rates, and why the 5 kHz input limit matters:

| **Flow rate (L/min)** | **Pulse frequency (Hz)** | **Status** |
| --- | --- | --- |
| 10 | 137 | Well inside the limit |
| 50 | 683 | Well inside the limit |
| 100 | 1367 | Well inside the limit |
| 200 | 2733 | Inside the limit |
| 365.9 | 5000 | X-DI4 maximum |

The one-second window sets the resolution. One pulse in a window equals 60 / 820 = **0.073 L/min**, so the flow reading changes in steps of that size. Total volume has far finer resolution because it counts every pulse (1.22 mL each).

## Software Setup

1. Install the Arduino IDE and add ESP32 support through **Tools → Board → Boards Manager**.
2. Connect the X-CPU to your computer over USB.
3. Select **ESP32S3 Dev Module** and the correct port.
4. Set **USB CDC On Boot** to Enabled, **Flash Size** to 16MB (128Mb), and **PSRAM** to QSPI PSRAM.

This sketch uses only the ESP32 core, so you install no extra libraries. Before you start, upload NORVI's X-DI4 test sketch from the NORVI-X-Version-02 GitHub repository to confirm each input reads correctly.

## Arduino Code

Each input has an interrupt that applies a debounce lockout and increments a counter. Once per second, the main loop reads and clears the counters atomically, adds them to 64-bit totals, and prints the results.

```javascript

                /*
  ESP32 pulse counter - NORVI X-CPU-ESPS3-X1 + X-DI4
  IN1 (IO5) : turbine flow meter, K factor = 820 pulses/litre
  IN2 (IO6) : production counter (24 V sensor, one pulse per part)
*/

#define FLOW_PIN 5                       // X-DI4 IN1
#define PART_PIN 6                       // X-DI4 IN2

const double   K_FACTOR         = 820.0; // pulses per litre
const uint32_t FLOW_DEBOUNCE_US = 100;   // flow meter: max 5 kHz = 200 us period
const uint32_t PART_DEBOUNCE_US = 20000; // production sensor: 20 ms lockout
const uint32_t WINDOW_MS        = 1000;  // measurement window

volatile uint32_t flowPulses = 0, partPulses = 0;   // reset every window
volatile uint32_t flowLastUs = 0, partLastUs = 0;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;

uint64_t totalFlowPulses = 0, totalParts = 0;       // 64-bit running totals
uint32_t windowStartMs = 0;

void IRAM_ATTR flowISR() {
  uint32_t now = micros();
  if ((uint32_t)(now - flowLastUs) >= FLOW_DEBOUNCE_US) {  // rollover-safe
    flowPulses++;
    flowLastUs = now;
  }
}

void IRAM_ATTR partISR() {
  uint32_t now = micros();
  if ((uint32_t)(now - partLastUs) >= PART_DEBOUNCE_US) {
    partPulses++;
    partLastUs = now;
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(FLOW_PIN, INPUT);
  pinMode(PART_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(FLOW_PIN), flowISR, RISING);
  attachInterrupt(digitalPinToInterrupt(PART_PIN), partISR, RISING);
  windowStartMs = millis();
  Serial.println("ESP32 pulse counter ready. Send 'r' to reset totals.");
}

void loop() {
  if (Serial.available() && Serial.read() == 'r') {
    totalFlowPulses = 0;
    totalParts = 0;
    Serial.println("Totals reset.");
  }

  uint32_t elapsed = millis() - windowStartMs;      // rollover-safe
  if (elapsed < WINDOW_MS) return;
  windowStartMs += elapsed;

  uint32_t fp, pp;
  portENTER_CRITICAL(&mux);                         // atomic read-and-clear
  fp = flowPulses;  flowPulses = 0;
  pp = partPulses;  partPulses = 0;
  portEXIT_CRITICAL(&mux);

  totalFlowPulses += fp;
  totalParts      += pp;

  double seconds     = elapsed / 1000.0;
  double freqHz      = fp / seconds;
  double flowLpm     = freqHz / K_FACTOR * 60.0;
  double totalLitres = totalFlowPulses / K_FACTOR;
  double partsPerMin = pp / seconds * 60.0;

  Serial.printf("FLOW  %4.0f Hz | %6.2f L/min | total %.3f L (%llu pulses)n",
                freqHz, flowLpm, totalLitres, (unsigned long long)totalFlowPulses);
  Serial.printf("PARTS %4lu in window | %5.1f parts/min | total %llun",
                (unsigned long)pp, partsPerMin, (unsigned long long)totalParts);
  if (freqHz > 5000.0) Serial.println("WARNING: input above the 5 kHz X-DI4 limit");
}
```

## Debounce and Overflow Handling

### Debounce

Each interrupt ignores any edge that arrives sooner than a lockout time after the last accepted edge. Choose the lockout per input:

| **Input** | **Lockout** | **Reasoning** |
| --- | --- | --- |
| Flow meter (IN1) | 100 µs | At 5 kHz the pulse period is 200 µs. A 100 µs lockout passes every real edge and rejects ringing. |
| Production sensor (IN2) | 20 ms | Rejects contact bounce and vibration. It limits counting to 50 parts per second, so lower it if your line runs faster. |

### Overflow

A pulse counter that runs for weeks overflows narrow integers. The sketch removes each risk:

| **Counter** | **Overflows after** | **How the sketch handles it** |
| --- | --- | --- |
| 32-bit pulse count at 5 kHz | 9.9 days | The ISR counter clears every second. A 64-bit total accumulates in the main loop. |
| 32-bit pulse count at 723 Hz | 68.8 days | Same fix. The 64-bit total lasts far longer than the hardware. |
| `micros()` timer | 71.6 minutes | `(uint32_t)(now - last)` stays correct across rollover. |
| `millis()` timer | 49.7 days | The window timer uses the same unsigned subtraction. |

Calculate litres from the pulse total with `double` arithmetic. Do not add small float increments each second, because rounding error grows over time. Totals reset when the controller restarts, so store them in non-volatile memory or on the SD card if you need permanent records.

### Why interrupts instead of the PCNT peripheral

The ESP32-S3 also has a hardware pulse counter peripheral (PCNT) with a built-in glitch filter. This tutorial uses GPIO interrupts because they work on any pin, keep the debounce logic visible, and handle the X-DI4's 5 kHz limit easily: 5,000 interrupts per second is a light load for a 240 MHz CPU.

### Keep totals across restarts

The running totals live in RAM, so a power cut resets them. Store them with the built-in `Preferences` library. Save once a minute instead of on every pulse to limit flash wear; a power cut then costs at most one minute of counts. Add these lines to the sketch:

```cpp

                #include <Preferences.h>
Preferences prefs;
uint32_t lastSaveMs = 0;

// in setup(), after Serial.begin():
prefs.begin("pulses", false);
totalFlowPulses = prefs.getULong64("flow", 0);
totalParts      = prefs.getULong64("parts", 0);

// in loop(), after the totals update (also save zeros in the 'r' branch):
if (millis() - lastSaveMs >= 60000) {
  lastSaveMs = millis();
  prefs.putULong64("flow", totalFlowPulses);
  prefs.putULong64("parts", totalParts);
}
```

## Test and Expected Output

Open the Serial Monitor at 115200 baud. With the meter running near 723 Hz and one part passing the sensor, the first window prints:

```
FLOW   723 Hz |  52.90 L/min | total 0.882 L (723 pulses)
PARTS    1 in window |  60.0 parts/min | total 1
```

The status LED on each X-DI4 channel lights while its input is active. Send the letter `r` in the Serial Monitor to reset both totals. The sketch reads the pins with `pinMode(pin, INPUT)`, exactly as NORVI's X-DI4 test sketch does, and counts one rising edge per pulse.

## Calibrate the K Factor

The datasheet K factor is a nominal value. For accurate litres, calibrate it at your normal flow rate:

1. Send `r` to reset the totals.
2. Run a known volume through the meter, for example 20 L into a measuring tank.
3. Read the total pulse count from the Serial Monitor.
4. Calculate K = total pulses / litres. A count of 16,400 pulses for 20 L gives K = 820. Enter the result as `K_FACTOR` and upload the sketch again.

## Troubleshooting

| **Symptom** | **Suggested action** |
| --- | --- |
| Flow count stays at 0 | Confirm the red lead has 24 V, the blue lead reaches DCOM, and the yellow lead reaches IN1. The channel LED lights when pulses arrive. |
| Erratic or too-high counts | Use shielded twisted cable, keep it away from motor and VFD wiring, and raise the lockout slightly. |
| Flow reads low at high flow | Confirm the pulse frequency stays under 5 kHz. A warning prints if the window frequency exceeds that limit. |
| Parts double count | Increase `PART_DEBOUNCE_US`. |
| Parts missed | Decrease `PART_DEBOUNCE_US` until it sits below your fastest real part interval. |
| Module not detected | Reseat the X-DI4 with power off. Confirm no other module uses IO5, IO6, IO7 or IO10. |

**Safety.**Disconnect all power before wiring, and let a qualified electrician install the system. Never use this counter as a safety function or an emergency-stop circuit.

## Applications

- Water, diesel or milk transfer metering with flow rate and total volume
- Pump discharge monitoring to spot dry running or blocked lines
- Tank filling and batch logging
- Counting bottles, boxes or parts on a conveyor, with parts per minute

## FAQ

**Can an ESP32 read a 24 V input directly?** No. The GPIO pins run at 3.3 V. The X-DI4 optocouplers isolate the 24 V signal and give the ESP32-S3 a safe logic level.

**How fast can the X-DI4 count?** Up to 5 kHz in fast input mode. With K = 820, that equals 365.9 L/min.

**Which pins does the counter input use?** IN1 to IN4 map to IO5, IO6, IO7 and IO10. To count on IN3 or IN4, add another interrupt the same way.

**Do totals survive a restart?** No. Save them to flash with the `Preferences` library or to the SD card.

## Conclusion

You now have a working ESP32 pulse counter that measures flow rate and volume from a turbine flow meter, counts production parts, rejects noise with per-input debounce, and stays accurate through timer and counter rollover. To go further, publish the values to ThingsBoard over MQTT, as the EC-M12 flow rate application does, or log totals to the SD card.

**Resources**

[NORVI X-CPU-ESPS3-X1 user guide](https://norvi.io/docs/norvi-x-cpu-esps3-x1-user-guide/) | [X-CPU datasheet](https://norvi.io/docs/norvi-x-cpu-esps3-x1-datasheet/)

[NORVI X-DI4 user guide](https://norvi.io/docs/norvi-x-di4-user-guide/) | [X-DI4 datasheet](https://norvi.io/docs/norvi-x-di4-datasheet/)
