1. Today's topic

Discrete inputs through optocouplers: what actually happens between an external 220 V / 24 V / 12 V signal and an ESP32/STM32 GPIO, and why the output may have pulsation, bounce, delays and false edges. The main idea: an optocoupler does not turn an external signal into an ideal logic level. It turns it into LED current, then phototransistor current, and only then do the pull and filtering circuits produce a level for the MCU.

2. Why this matters

Traffic-light phase inputs may be:

text
220 V AC
24 V DC
12 V DC
an industrial line
a long cable with interference
a signal through a relay/optocoupler/module

The MCU sees only GPIO 0/1. Between them:

  • AC after an optocoupler may pulse at 50/100 Hz;
  • the phototransistor leaves saturation slowly;
  • optocoupler CTR varies;
  • a long cable picks up interference;
  • a weak pull resistor produces slow edges;
  • GPIO interrupts may capture false events.

Therefore, you cannot simply do this:

c
if (gpio_get_level(INPUT_RED)) {
    phase = RED;
}

3. The basic circuit

text
External signal
   -> current-limiting resistor
   -> LED inside the optocoupler
   -> optical coupling
   -> phototransistor
   -> pull-up / pull-down
   -> ESP32/STM32 GPIO

A typical circuit:

text
3.3 V
 │
 Rpullup
 │
 ├── GPIO
 │
 optocoupler collector
 emitter
 │
GND

Inversion is common:

text
external signal present -> GPIO = 0
external signal absent  -> GPIO = 1

Make this explicit in code:

c
#define INPUT_ACTIVE_LEVEL 0

4. DC is simpler than AC

For 24 V DC:

text
24 V present -> optocoupler LED stays on
24 V absent  -> LED is off

For 220 V AC:

text
+ half-cycle
0
- half-cycle
0

If the circuit conducts during only one half-cycle, the output pulses at 50 Hz. If it conducts during both, it pulses at 100 Hz. Presence of 220 V AC therefore often needs to be detected as:

text
were there pulses within the last 20-50 ms?

rather than:

text
what is the GPIO level right now?

5. A software AC detector

c
#define AC_PRESENT_TIMEOUT_MS 40
typedef struct {
    int64_t last_active_us;
    bool present;
} ac_input_detector_t;

Logic:

text
if an active level is observed:
    last_active_us = now
if now - last_active_us < 40 ms:
    AC is present
else:
    AC is absent

At 50 Hz, the period is 20 ms. A 30–50 ms window normally spans several half-cycles.

6. Pull-up

The MCU’s internal pull resistor is often weak and varies considerably. An external pull-up is preferable for an optocoupler input:

text
10 kΩ  - lower power consumption, slower edges
4.7 kΩ - faster and more robust
2.2 kΩ - stronger pull, but more phototransistor current

7. Optocoupler CTR

CTR = phototransistor current / LED current. It depends on:

  • The individual device;
  • temperature;
  • ageing;
  • LED current;
  • manufacturing batch;
  • saturation conditions.

Check the worst case:

text
minimum LED current
minimum CTR
maximum temperature
ageing
the strongest pull resistor

8. An RC filter

An RC filter smooths interference but adds delay.

text
τ = R × C

An example:

text
R = 10 kΩ
C = 100 nF
τ = 1 ms

By contrast, 100 kΩ and 1 µF give 100 ms, which may delay events too much.

9. Schmitt trigger

For slow or noisy edges, a buffer with hysteresis is useful:

text
external signal
-> protection
-> current limiting
-> optocoupler
-> pull-up
-> RC / filter
-> Schmitt trigger / comparator
-> MCU GPIO

Hysteresis removes oscillation near the threshold.

10. GPIO interrupts or polling

GPIO interrupt:

  • Fast timestamp capture;
  • good for clean edges;
  • poor for noisy AC after an optocoupler.

Polling:

  • Filtering is simpler;
  • fewer noise-related problems;
  • convenient for HIL;
  • timestamp uncertainty can be up to one polling period.

For traffic-light inputs, it is better to start with polling every 1–5 ms.

11. A DC input detector

For DC, an integrator can be used:

c
typedef struct {
    uint8_t acc;
    bool stable;
} dc_input_filter_t;
#define FILTER_MAX 10
#define FILTER_ON_THRESHOLD  8
#define FILTER_OFF_THRESHOLD 2
static void dc_input_update(dc_input_filter_t *f, bool raw_active)
{
    if (raw_active) {
        if (f->acc < FILTER_MAX) f->acc++;
    } else {
        if (f->acc > 0) f->acc--;
    }
    if (f->acc >= FILTER_ON_THRESHOLD) {
        f->stable = true;
    } else if (f->acc <= FILTER_OFF_THRESHOLD) {
        f->stable = false;
    }
}

12. Connection to the architecture

text
input_driver
  reads raw GPIO
input_filter
  converts raw into stable_present
phase_detector
  determines RED/YELLOW/GREEN/OFF/CONFLICT
phase_task
  creates phase_event_t with a timestamp
transport_task
  sends the event

13. Phase conflicts

text
RED = 1, YELLOW = 0, GREEN = 0 -> RED
RED = 0, YELLOW = 1, GREEN = 0 -> YELLOW
RED = 0, YELLOW = 0, GREEN = 1 -> GREEN
RED = 0, YELLOW = 0, GREEN = 0 -> OFF/UNKNOWN
RED = 1, YELLOW = 1, GREEN = 0 -> RED_YELLOW or TRANSITION
RED = 1, YELLOW = 0, GREEN = 1 -> CONFLICT

Impossible combinations must be logged explicitly.

14. Common mistakes

  • Reading an AC input as an ordinary level;
  • using one filter for AC and DC;
  • choosing excessively long debounce;
  • omitting an external pull resistor;
  • forgetting inversion;
  • doing heavy processing in a GPIO ISR;
  • not measuring the actual signal with an oscilloscope.

15. Practical task

Create INPUT_FILTER_POLICY.md:

markdown
# Input filter policy
## Input types
- AC optocoupler input:
  Detect presence of pulses within a time window.
  Do not use instantaneous GPIO level as final state.
- DC optocoupler input:
  Use integrator/debounce filter.
## Timing
- Polling period: 1-5 ms
- AC present timeout: start with 40 ms for 50 Hz mains
- Stable confirm time: 20-50 ms
- Timestamp policy:
  Capture raw sample timestamp immediately.
## Signal inversion
All input channels must define active level explicitly.
## Diagnostics
Track:
- raw pulse count
- stable transitions
- glitches
- time since last pulse
- impossible phase combinations

16. A short recap

An optocoupler does not provide an ideal logic level. For an AC input, the correct model is:

text
the signal is active
if there were pulses within the last 30-50 ms

rather than:

text
the signal is active
if gpio_get_level() equals active_level right now
For an AC optocoupler input, why can one instantaneous inactive GPIO sample be misleading?

Exercise

Using τ=R×C, calculate the nominal time constant for R=10 kΩ and C=100 nF. Explain what this number does not establish about the complete input detector.

Self-check criteria: Calculate 1 ms and distinguish an RC time constant from full detector timing or hardware validation.

Show the supplied answer

τ=10000×100×10^-9 s=0.001 s=1 ms. It is an RC time constant, not the complete phase-detection latency or a validated threshold. Optocoupler behaviour, signal shape, polling and confirmation policy also affect the detector; the source’s examples require measurement for the actual design.