Production counting with IoT sensors replaces tally sheets and end-of-shift estimates with exact, live part counts. In this tutorial you will count good and rejected parts with photoelectric sensors and a Raspberry Pi, make the counter survive restarts and network outages, and publish retained shift totals over MQTT so every board, tablet and report shows the same number.
Why Automatic Counting Matters
Hand-written counts are late, rounded and occasionally optimistic. They arrive after the shift, when nothing can be done about a shortfall. An automatic counter shows the gap to target while there is still time to react, and it provides the quality and performance inputs that OEE needs. It is also one of the simplest IoT projects on the shop floor: one sensor, one edge device, one topic.
Choosing a Counting Point
| Counting point | Pros | Cons |
|---|---|---|
| PLC part counter or "part complete" bit | Exact, already debounced | Requires PLC access and changes |
| Photoelectric sensor at the station exit | Retrofit without touching the PLC; works on most parts | Must handle gaps, touching parts, reflective surfaces |
| Inductive sensor | Robust for metal parts, ignores dirt | Short range, metal only |
| Reject chute or scrap bin sensor | Gives scrap count for quality rate | Needs a second sensor |
| Packaging station (boxes, then multiply) | Counts what is actually shipped | Delayed, hides scrap between station and packing |
Count as close as possible to where the part becomes "good". If the line has an inspection station, count passed parts after it and rejected parts at the reject path. Good count and scrap count together give total output.
For photoelectric sensors, through-beam types are the most reliable on conveyors because touching or shiny parts can confuse diffuse sensors. Mount the sensor so that each part breaks the beam exactly once, with a gap between parts.
Wiring
Industrial sensors run on 24 V and switch PNP or NPN outputs. Feed them into the Raspberry Pi through an optocoupler input board; the Pi's GPIO pins tolerate only 3.3 V. Example assignment:
| Input | GPIO (BCM) | Signal |
|---|---|---|
| Good part sensor | 17 | Through-beam receiver output |
| Reject sensor | 27 | Reject chute sensor |
| Shift reset (optional) | 22 | Key switch or supervisor button |
Topic Design for Counts and Shift Totals
connectmq/plant1/packaging/line2/counter/count every N seconds, not retained
connectmq/plant1/packaging/line2/counter/shift retained shift totals
connectmq/plant1/packaging/line2/counter/status retained online / offline
The shift message is retained, so a dashboard opening mid-shift immediately shows the current totals. Its payload contains absolute totals, not increments:
{
"shift": "2026-10-16-A",
"start": 1760594400,
"good": 1843,
"reject": 27,
"target": 2400,
"ts": 1760600000
}
Publishing absolute totals is the key design choice. If a message is lost or delivered twice, the next one still contains the correct total; increments would drift.
The Production Counting IoT Edge Script
The script counts edges on both inputs, saves the totals to a small file every few seconds so a reboot does not lose them, detects shift changes from a schedule, and publishes the retained totals. Install the dependencies in a virtual environment with pip install gpiozero lgpio paho-mqtt.
#!/usr/bin/env python3
"""Production counting IoT edge: sensors -> persistent counts -> retained MQTT totals."""
import json
import os
import threading
import time
from datetime import datetime, timedelta
import paho.mqtt.client as mqtt
from gpiozero import DigitalInputDevice
BROKER = "broker.connectmq.com"
BASE = "connectmq/demo/pc-9d1x/line2/counter"
STATE_FILE = "/var/lib/pc-counter/state.json"
PUBLISH_EVERY = 5 # seconds
SHIFTS = [("A", 6), ("B", 14), ("C", 22)]
TARGET_PER_SHIFT = 2400
lock = threading.Lock()
def current_shift(now=None):
now = now or datetime.now()
starts = []
for name, hour in SHIFTS:
start = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if start > now:
start -= timedelta(days=1)
starts.append((start, name))
start, name = max(starts)
return f"{start:%Y-%m-%d}-{name}", int(start.timestamp())
def load_state():
shift_id, start = current_shift()
try:
with open(STATE_FILE) as f:
state = json.load(f)
if state.get("shift") == shift_id:
return state
except (FileNotFoundError, ValueError):
pass
return {"shift": shift_id, "start": start, "good": 0, "reject": 0}
def save_state(state):
tmp = STATE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f)
os.replace(tmp, STATE_FILE) # atomic on the same filesystem
state = load_state()
def count(field):
def handler():
with lock:
state[field] += 1
return handler
good_in = DigitalInputDevice(17, pull_up=True, bounce_time=0.01)
reject_in = DigitalInputDevice(27, pull_up=True, bounce_time=0.01)
good_in.when_activated = count("good")
reject_in.when_activated = count("reject")
def on_connect(client, userdata, flags, reason_code, properties):
print("Connected:", reason_code)
client.publish(f"{BASE}/status", "online", qos=1, retain=True)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="pc-9d1x-line2")
client.will_set(f"{BASE}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.connect(BROKER, 1883, keepalive=30)
client.loop_start()
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
while True:
time.sleep(PUBLISH_EVERY)
shift_id, start = current_shift()
with lock:
if shift_id != state["shift"]:
final = dict(state, ts=int(time.time()), final=True)
client.publish(f"{BASE}/shift-closed", json.dumps(final), qos=1)
state.update(shift=shift_id, start=start, good=0, reject=0)
snapshot = dict(state, target=TARGET_PER_SHIFT, ts=int(time.time()))
save_state(state)
client.publish(f"{BASE}/shift", json.dumps(snapshot), qos=1, retain=True)
client.publish(f"{BASE}/count", json.dumps(snapshot), qos=0)
Points worth highlighting:
- Atomic save: writing to a temporary file and renaming it means a power cut during the write cannot corrupt the state file.
- Shift rollover on the edge: the device closes the shift itself and publishes a final record on
shift-closed, so the total is correct even if no server is listening at that moment. A historian subscribed with a persistent session receives it later. - Debounce:
bounce_timefilters electrical noise; parts that pass faster than the debounce window would be undercounted, so set it from your maximum part rate.
Create /var/lib/pc-counter with permissions for the service user and run the script under systemd.
Watching the Counts
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-view-$RANDOM \
-t "connectmq/demo/pc-9d1x/line2/counter/#" -v
The retained shift total and status arrive first, then updates every five seconds. On the public MQTT test broker anyone can read these topics, which is fine for a bench test with a hand waved through a light barrier, but not for a real line. For production, add client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD"), client.tls_set(), use your issued client ID and connect to port 8883.
Validating the Counter
Before anyone trusts the numbers, verify them:
- Run a known batch (for example 200 parts counted by hand) and compare.
- Test with parts touching each other and with the fastest realistic line speed.
- Check behaviour across a reboot in the middle of a run: the count must continue, not restart at zero.
- Disconnect the network for a few minutes: counting continues locally, and the next published total includes the parts made while offline.
- Compare one full shift with the packaging or ERP count; differences should be explainable (scrap, rework, WIP).
Common Problems
| Problem | Cause | Fix |
|---|---|---|
| Double counts | Part edges or holes break the beam twice | Increase debounce, reposition sensor, use through-beam |
| Missed counts | Parts touching, debounce too long | Add gap on conveyor, shorten debounce, use PLC signal |
| Count reset after power cut | State not persisted | Persist to file as shown, or use PLC counter |
| Dashboards show yesterday's shift | Retained message from a stopped device | Monitor status; show "offline" prominently |
| Totals differ between screens | Screens compute from increments | Always display the retained absolute total |
Displaying Counts on the Line
The most effective display is a simple one: good parts so far, target so far (pro-rated for elapsed shift time), and the difference, in large digits visible from the workstation. A browser on a wall screen can subscribe over secure WebSocket to the retained shift topic and compute the pro-rated target from start and target, so no extra server is needed. Colour the difference green when ahead and red when behind, and show the reject count next to it so speed is never celebrated at the expense of quality.
During commissioning, the live topic explorer in the ConnectMQ portal shows each counter's retained total and status, which makes it easy to check that every line publishes before the screens go up.
Using the Counts
Live counts against target are the simplest motivating display on a line. Combined with running time from machine status monitoring and cycle data from cycle time monitoring, they feed real-time OEE monitoring directly: good count over total count is the quality rate, and total count against ideal cycle time gives performance. Energy teams use the same good-part count to compute energy per part.
The broker is the shared source of truth for all of these consumers, which is why it pays to understand what an MQTT broker does before scaling to many lines. The smart manufacturing guide shows where counting fits in a broader roadmap.
Scale Your Counters on ConnectMQ
ConnectMQ is a fully managed MQTT service with TLS, MQTT 5, retained messages, an ISA-95 devices view and threshold alerts, for example on a good count falling behind plan. Create a free Serverless broker with no credit card and connect your first counter in minutes.
Frequently Asked Questions
Should a production counter publish increments or totals?
Totals. An absolute shift total in a retained message is correct even if messages are lost or duplicated, and new subscribers see the current value immediately.
What happens to counts if the network goes down?
The edge device keeps counting locally and persists the totals. When the connection returns, the next published message contains the complete total including parts made during the outage.
Which sensor is best for production counting IoT projects?
Through-beam photoelectric sensors are the most reliable general choice on conveyors. Inductive sensors suit metal parts, and a PLC part-complete signal is best when available.
How do I count scrap separately?
Add a second sensor on the reject path or use the inspection station's reject signal, and publish good and reject counts in the same message so the quality rate is always consistent.



