
Production counter with ESP32 without losing pulses
The debounce copied from a button tutorial becomes a production ceiling: 50 ms limits the line to 1,200 pieces per minute. IRAM interrupt and isolated input.
Hardware requirements:
- Placa ESP32 DevKit
- Sensor indutivo PNP M12, alimentado em 24 V
- Optoacoplador PC817 com resistor de 2,2 kΩ de 1/2 W
- Fonte de 24 V do painel e fonte separada para o ESP32
- Multímetro para conferir a saída do sensor antes de ligar a placa
Download this tutorial's materials:
- Projeto completo (ZIP)148 KBplatformio.ini, firmware com interrupção em IRAM, debounce por microssegundos, contador de rejeitados, contagem acumulada com sessão de boot, README e o manual em PDF.
- Manual em PDF (6 páginas)176 KBTabela de teto por janela de debounce, cálculo do resistor do PC817, regras da rotina de interrupção, contrato MQTT e o teste de ritmo máximo.
Want to receive new manuals and industrial schematics? Join our channel on Telegram / WhatsApp
The counter that counts less than the line produced
A production counter is the project everyone considers simple: one sensor, one interrupt, one number that goes up. It is simple until the supervisor compares the panel number with the shipping count and finds a difference of hundreds of pieces — always short, always on the fastest shifts.
The sensor is almost never the reason. It is a line copied from a button tutorial: a 50 ms debounce, added to solve a problem an inductive sensor does not have, imposing a production ceiling nobody calculated.
This tutorial covers the four points that decide whether the number is trustworthy: why a solid-state sensor does not bounce, how debounce becomes a ceiling, how to isolate 24 V from the ESP32 without damaging the pulse, and why the counter must be cumulative instead of incremental.
The inductive sensor does not bounce
Bounce is a mechanical contact phenomenon. A switch, relay or button physically closes two pieces of metal, and the contact jumps for a few milliseconds before settling. That is why every button tutorial starts with debounce.
An M12 PNP inductive sensor has no contact. It is an oscillator with an output transistor: when the metal target enters the field, the output saturates. There is no bounce and no microseconds of mechanical indecision. The signal it delivers is already clean.
That does not mean the signal reaches the ESP32 cleanly. A production line has a frequency inverter switching tens of amps a few centimeters from your cable, a contactor opening an inductive load and grounding nobody reviewed. The noise at the input is electrical coupling, not bounce, and the distinction matters because the remedy is different: shielded cable, twisted pair, physical separation from power conductors and galvanic isolation. Software does not solve coupling; it only hides the symptom until hiding it also costs pieces.
Debounce is a production ceiling
A time-based debounce ignores every pulse that arrives before the window expires. That is exactly what you want against bounce and exactly what you do not want against fast production: the legitimate pulse from the next part is ignored too.
The calculation is direct. The ceiling in pieces per minute is 60 divided by the window in seconds:
| 1 | window pulses/s production ceiling |
| 2 | 50 ms 20 1,200 pieces/min |
| 3 | 20 ms 50 3,000 pieces/min |
| 4 | 12 ms 83 5,000 pieces/min |
| 5 | 10 ms 100 6,000 pieces/min |
| 6 | 5 ms 200 12,000 pieces/min |
| 7 | 2 ms 500 30,000 pieces/min |
This project requires 5,000 pieces per minute, or one piece every 12 ms. A 50 ms debounce does not reject half the pieces: it rejects four out of five, and the panel shows 1,200 with complete confidence. No error appears in the log because, from the firmware's point of view, it did exactly what it was told.
The practical rule is that the window must be much shorter than the minimum interval between pieces, with room for speed variation. For a 12 ms interval, a 2 ms window is comfortable — it filters a switching transient that lasts microseconds and still leaves a sixfold margin.
There is also a ceiling your code cannot change: the sensor's own switching frequency, stated in its datasheet. A typical M12 is between 500 Hz and 1 kHz. If your sensor switches at 500 Hz, the physical limit is 30,000 pieces per minute regardless of the firmware. Check that number before promising anything.
Isolation: 24 V must not enter the ESP32
The PNP sensor output delivers the 24 V control supply when active. The ESP32 GPIO tolerates 3.3 V. Connecting one to the other destroys the input and may take the regulator with it.
The optocoupler solves two problems at once: it adapts the level and separates the grounds. The second matters more than it may seem. The panel's 24 V ground and the ESP32 ground often sit at different electrical points; connecting them creates a current path through which inverter noise goes straight into your electronics.
The PC817 has an LED on one side and a phototransistor on the other, with no electrical connection between them. The 24 V side needs a series resistor to limit LED current.
Resistor calculation, and why 1 kΩ burns
The recommended continuous forward current for the PC817 is around 10 mA, and the LED drop is approximately 1.2 V. The resistor must absorb the remainder:
| 1 | R = (24 V - 1.2 V) / 10 mA = 2,280 ohms -> 2.2 k commercial value |
| 2 | P = I^2 x R = (0.0104)^2 x 2200 = 0.24 W -> 1/2 W resistor |
Many internet examples use 1 kΩ, which was the right value for 5 V. At 24 V it delivers:
| 1 | I = (24 - 1.2) / 1000 = 22.8 mA |
| 2 | P = (0.0228)^2 x 1000 = 0.52 W |
Twenty-two milliamps do not burn the LED immediately, but they accelerate optocoupler gain degradation. The component loses sensitivity over months until it starts missing pulses intermittently. The 1/4 W resistor found in most drawers is dissipating twice what it can handle and burns. Use 2.2 kΩ at 1/2 W.
On the ESP32 side, the phototransistor emitter goes to GND and the collector goes to the GPIO with a pull-up resistor. When the sensor activates, the phototransistor conducts and pulls the pin low. The logic is intentionally inverted: part present is low, and the interrupt triggers on the falling edge.
The ESP32 internal pull-up is about 45 kΩ. It works, but makes the rising edge slow. That does not hurt at this project's rate. If you push the rate near the sensor limit, add an external 10 kΩ pull-up: the edge becomes sharper and the phototransistor leaves conduction faster.
Interrupt: what it may and may not do
The interrupt routine must be short. It runs while the rest of the system is stopped, and everything you do inside it is time during which the network, watchdog and count wait.
Three rules avoid most lockups:
The function needs the IRAM_ATTR attribute so it stays in internal RAM. Without it, the routine may be in a flash region that is unavailable during a write, and an interrupt arriving at that moment can crash the board.
Every variable shared between the interrupt and the main loop must be volatile. Otherwise the compiler keeps the value in a register and the loop reads a number that never changes.
No Serial.print, memory allocation, delay or MQTT publication inside the interrupt. Save the data and leave; the main loop prints and publishes it.
Debounce lives inside the interrupt, comparing the current instant with the last accepted pulse. Use the microsecond clock, which has enough resolution for a 2 ms window:
| 1 | constexpr int SENSOR_PIN = 4; |
| 2 | constexpr int64_t WINDOW_US = 2000; |
| 3 | volatile uint32_t count = 0; |
| 4 | volatile int64_t lastPulseUs = 0; |
| 5 | volatile uint32_t rejected = 0; |
| 6 | portMUX_TYPE lock = portMUX_INITIALIZER_UNLOCKED; |
| 7 | // The interrupt does three things and exits: reads the clock, decides |
| 8 | // whether the pulse is valid and increments. Anything else steals time |
| 9 | // from the next part. |
| 10 | void IRAM_ATTR onPartDetected() { |
| 11 | const int64_t now = esp_timer_get_time(); |
| 12 | portENTER_CRITICAL_ISR(&lock); |
| 13 | if (now - lastPulseUs >= WINDOW_US) { |
| 14 | lastPulseUs = now; |
| 15 | count++; |
| 16 | } else { |
| 17 | // Do not simply discard it: a rejected pulse is a diagnostic. If this |
| 18 | // counter rises with production, the window is too large. |
| 19 | rejected++; |
| 20 | } |
| 21 | portEXIT_CRITICAL_ISR(&lock); |
| 22 | } |
| 23 | void configureCounter() { |
| 24 | pinMode(SENSOR_PIN, INPUT_PULLUP); |
| 25 | attachInterrupt(digitalPinToInterrupt(SENSOR_PIN), onPartDetected, FALLING); |
| 26 | } |
Notice the rejected counter. It turns a silent ceiling into a visible defect: if the line speeds up and this number starts rising with it, the window is too large. Without it, the firmware loses pieces without ever saying that it did.
Count cumulatively, never incrementally
The temptation is to publish how many pieces passed since the last send. That is wrong, and the error appears only on the day the network becomes unstable.
If the message saying “12 pieces” is lost, those 12 pieces disappear forever. The system on the other side cannot know something is missing: it received a plausible sequence of numbers with no visible gap.
Publishing the cumulative total since boot fixes this by itself. If one message is lost, the next one already contains the correct total, and the management system calculates the difference between consecutive readings. The data heals without intervention.
The other side must handle two things. First, the counter restart: when the ESP32 restarts, the total returns to zero, and a negative difference must be read as a restart, not negative production. Publish a boot-session identifier so the distinction is explicit.
Second, never accept a reset-counter command with retention. A retained command is delivered again at every client reconnect and resets the shift's production by itself in the middle of the afternoon. The same rule from the SCADA integration article applies: retain state, never commands.
Pieces per minute without noise
Calculating rate from the interval between two consecutive pieces produces a number that jumps constantly: every millisecond variation becomes dozens of pieces per minute up or down, and the chart becomes unreadable.
Use a fixed time window. At every publishing interval, take the difference between the current count and the previous published count and convert it:
| 1 | pieces per minute = (count - previousCount) * 60000 / intervalMs |
With a 10-second interval, the result is stable enough for an alarm and still responds quickly to a line stop. A very short window returns noise; a very long one hides the stop for too long.
MQTT contract
Publish the cumulative count, rate, boot session and quality. Use a stable topic with the value in the payload:
| 1 | { |
| 2 | "ts": 1755990000, |
| 3 | "count_total": 184523, |
| 4 | "rate_ppm": 4870, |
| 5 | "boot_id": 7, |
| 6 | "rejected": 0, |
| 7 | "quality": 192 |
| 8 | } |
The rejected field travels on purpose: it proves debounce is not eating production. In normal operation it stays at zero and nobody looks at it. When the line accelerates beyond the expected rate, it rises before the difference appears in inventory — turning a silent error into an alarm.
Retain telemetry and gateway status so the panel comes up populated after a restart. Timestamp the reading, with the clock synchronized by NTP; without synchronized NTP, publish uncertain quality instead of inventing a time.
Assembly and validation
Step 1: check the sensor before the ESP32
Power the inductive sensor from the panel's 24 V and measure its output with a multimeter, with the target inside and outside the field. You should see the output move from near zero to near 24 V. Only then connect anything to the ESP32. Half of counting problems end here, with a badly positioned sensor or a detection distance outside its range.
Step 2: build the isolated input
The sensor output positive goes to the 2.2 kΩ, 1/2 W resistor and then to the PC817 anode. The cathode goes to 0 V on the 24 V side. On the other side, connect the emitter to ESP32 GND and the collector to GPIO 4. Confirm that the two grounds do not touch at any point: if they are connected, the isolation is decoration.
Step 3: validate the pulse before counting
Before enabling the interrupt, read the pin in a loop and print its level. Move the target slowly by hand and confirm that the value alternates. If it does not, the problem is electrical and no firmware adjustment will fix it.
Step 4: count slowly and check by hand
Pass exactly 20 pieces slowly and compare with the serial monitor. It must match exactly. If it counts too many, noise is entering or the debounce is too short for your environment. If it counts too few, the pulse is too short to detect or the window is already too large.
Step 5: the test almost nobody performs
Reproduce the expected maximum rate. If the bench cannot do it, simulate it: generate pulses with a second ESP32 or a signal generator at the target frequency, using a known number of pulses, and check that the final count matches.
For 5,000 pieces per minute, generate 83 pulses per second for one minute and confirm the total is 5,000, with rejected at zero. This is the test that separates a counter that works from one that merely looks like it works. It is also the only honest way to support a datasheet number.
Step 6: check restart behavior
Restart the ESP32 while counting. The total returns to zero and the session identifier must change. Confirm that the other system interprets this as a restart, not negative production or a jump of hundreds of thousands of pieces.
Troubleshooting
It counts less than the line produces. The debounce window is too large for the rate. Look at the rejected counter: if it follows production, that is the cause. Reduce the window according to the opening table.
It counts too many, especially when the inverter starts. Noise is coupled into the cable. Do not increase debounce as the first reaction, because that trades a visible error for an invisible ceiling. Treat the cause: shielded cable grounded at one end only, twisted pair, separation from power conductors and a checked panel ground.
Counting freezes after hours. Look for heavy work inside the interrupt or a missing IRAM_ATTR. Also check that the main loop has no long block preventing publication.
The total returns to zero by itself. A reset command was published with retention, or the restart was not handled. Check the session identifier and the command-topic retention policy.
Measured production does not match shipped production, but rejected is zero. The problem is probably before the ESP32: the target passes outside the sensor range, the part does not trigger because of its position, or the counting point does not correspond to the shipping point.
About validating this material
The package firmware was written from the documented behavior of the ESP32, PC817 and PNP inductive sensors, and reviewed line by line. The resistor, dissipation and production-ceiling calculations are in the text so you can check them instead of taking them on faith.
It was not assembled on a bench with a real sensor and production line. The ceiling figures come from arithmetic, not measurement, and Step 5 exists so you can produce your own evidence before trusting the number to someone. Where I did not measure, I say that I did not measure.
