1. Today's topic

An algorithm for detecting a traffic-light phase from an AC signal after an optocoupler: a pulse window, stability, suppression of false OFF events and correct timestamps. The main task: distinguish genuine phase switching from 50/100 Hz pulsation, brief dropouts, interference and transitional states.

2. Why this matters

When RED is actually on, GPIO may look like this:

text
__--________--________--________--____

Reading the level “right now” may produce a false sequence:

text
RED -> OFF -> RED -> OFF -> RED

If this is sent over UDP/TCP/MQTT, the camera or Jetson receives spurious transitions. The algorithm must detect pulses within a time window.

3. Three levels of state

For each input:

text
raw_active
present
stable

11.3.1 raw_active

The instantaneous GPIO level.

11.3.2 present

The signal is present if an active pulse occurred recently:

text
if an active pulse occurred within the last N ms:
    present = true
else:
    present = false

11.3.3 stable

present has remained unchanged long enough:

text
present changes
    wait for CONFIRM_TIME
if present has not changed again during this time:
    stable = present

stable is the appropriate value to pass to phase_detector.

4. Different time intervals

At least three intervals are needed:

text
AC_PRESENT_TIMEOUT_US
  how long to wait between pulses and still consider AC present
STABLE_ON_US
  how long present=true must hold before considering the phase on
STABLE_OFF_US
  how long present=false must hold before considering the phase off

Often:

text
STABLE_ON_US  = 20-30 ms
STABLE_OFF_US = 50-100 ms

Confirm switching off more cautiously to avoid reacting to one or two missing pulses.

5. Timestamp policy

There are different timestamps:

text
t_raw_first       - the first raw indication of a change
t_present_change  - when present changes
t_stable_change   - when the change is confirmed
t_event_emit      - when the event is sent to the queue

A poor choice is to timestamp the network send. It is better to retain several times:

c
typedef struct {
    traffic_phase_t old_phase;
    traffic_phase_t new_phase;
    int64_t detected_us;
    int64_t confirmed_us;
    int64_t emitted_us;
    uint32_t raw_mask;
    uint32_t stable_mask;
} phase_event_t;

Then delays can be calculated:

text
filter_delay = confirmed_us - detected_us
queue_delay  = emitted_us - confirmed_us
network_delay = receive_time - emitted_us

6. AC channel update

seen_active prevents false presence at startup. previous_raw_active counts only false-to-true transitions observed by polling. This remains a sampled edge counter, not a hardware pulse counter: polling may miss a pulse shorter than its period. Refresh last_active_us for every active sample so a sustained active level remains present. Zero-initialize the structure once before the loop and use monotonic time from one boot.

c
typedef struct {
    bool seen_active;
    bool previous_raw_active;
    bool present;
    bool stable;
    uint64_t last_active_us;
    uint64_t present_changed_us;
    uint32_t pulse_count;
} ac_channel_example_t;

#define AC_PRESENT_TIMEOUT_US 45000ULL
#define AC_STABLE_ON_US       25000ULL
#define AC_STABLE_OFF_US      70000ULL

static void ac_channel_example_update(ac_channel_example_t *ch,
                                      bool raw_active,
                                      uint64_t now_us)
{
    if (raw_active) {
        ch->seen_active = true;
        ch->last_active_us = now_us;
        if (!ch->previous_raw_active) ch->pulse_count++;
    }
    ch->previous_raw_active = raw_active;
    bool new_present = ch->seen_active &&
        (now_us - ch->last_active_us <= AC_PRESENT_TIMEOUT_US);
    if (new_present != ch->present) {
        ch->present = new_present;
        ch->present_changed_us = now_us;
    }
    uint64_t required_us = ch->present ? AC_STABLE_ON_US : AC_STABLE_OFF_US;
    if (ch->stable != ch->present &&
        now_us - ch->present_changed_us >= required_us) {
        ch->stable = ch->present;
    }
}

Require an OFF candidate to last 200000 us, an ordinary phase candidate 100000 us, and do not add this hold delay to CONFLICT. Call phase_candidate_ready after updating candidate/candidate_since_us and before committing current. To report CONFLICT immediately, changing the candidate must not unconditionally return before this check. The AC and stable filters add their own delay before the candidate exists; this helper does not remove it.

c
#define MIN_PHASE_HOLD_US 100000ULL
#define OFF_REPORT_US     200000ULL

static bool phase_candidate_ready(traffic_phase_t phase,
                                  uint64_t candidate_since_us,
                                  uint64_t now_us)
{
    if (phase == TRAFFIC_PHASE_CONFLICT) return true;
    uint64_t required_us = phase == TRAFFIC_PHASE_OFF
        ? OFF_REPORT_US : MIN_PHASE_HOLD_US;
    return now_us - candidate_since_us >= required_us;
}
  • Boot without active samples: present=false, pulse_count=0.
  • Ten consecutive active samples: pulse_count=1.
  • active→inactive→active: pulse_count=2.
  • An OFF candidate at 199999 us is not committed; at 200000 us it may be committed.
  • Check CONFLICT in the same iteration in which the new candidate appears.
c
typedef struct {
    bool raw_active;
    bool present;
    bool stable;
    int64_t last_raw_active_us;
    int64_t present_changed_us;
    int64_t stable_changed_us;
    int64_t candidate_on_us;
    int64_t candidate_off_us;
    uint32_t pulse_count;
    uint32_t stable_change_count;
    uint32_t short_drop_count;
    uint32_t glitch_count;
} ac_channel_t;
#define AC_PRESENT_TIMEOUT_US   45000
#define AC_STABLE_ON_US         25000
#define AC_STABLE_OFF_US        70000
static void ac_channel_update(ac_channel_t *ch,
                              bool raw_active,
                              int64_t now_us)
{
    ch->raw_active = raw_active;
    if (raw_active) {
        ch->last_raw_active_us = now_us;
        ch->pulse_count++;
    }
    bool new_present =
        (now_us - ch->last_raw_active_us) <= AC_PRESENT_TIMEOUT_US;
    if (new_present != ch->present) {
        ch->present = new_present;
        ch->present_changed_us = now_us;
        if (new_present) {
            ch->candidate_on_us = now_us;
        } else {
            ch->candidate_off_us = now_us;
        }
    }
    if (ch->present != ch->stable) {
        int64_t age_us = now_us - ch->present_changed_us;
        int64_t need_us = ch->present ? AC_STABLE_ON_US : AC_STABLE_OFF_US;
        if (age_us >= need_us) {
            ch->stable = ch->present;
            ch->stable_changed_us = now_us;
            ch->stable_change_count++;
        }
    }
}

7. Phase decoding

c
#define PHASE_MASK_RED     (1 << 0)
#define PHASE_MASK_YELLOW  (1 << 1)
#define PHASE_MASK_GREEN   (1 << 2)
static uint32_t make_stable_mask(bool red, bool yellow, bool green)
{
    uint32_t m = 0;
    if (red) m |= PHASE_MASK_RED;
    if (yellow) m |= PHASE_MASK_YELLOW;
    if (green) m |= PHASE_MASK_GREEN;
    return m;
}
typedef enum {
    TRAFFIC_PHASE_UNKNOWN = 0,
    TRAFFIC_PHASE_OFF,
    TRAFFIC_PHASE_RED,
    TRAFFIC_PHASE_YELLOW,
    TRAFFIC_PHASE_GREEN,
    TRAFFIC_PHASE_RED_YELLOW,
    TRAFFIC_PHASE_CONFLICT,
} traffic_phase_t;
static traffic_phase_t decode_phase(uint32_t mask)
{
    switch (mask) {
    case 0:
        return TRAFFIC_PHASE_OFF;
    case PHASE_MASK_RED:
        return TRAFFIC_PHASE_RED;
    case PHASE_MASK_YELLOW:
        return TRAFFIC_PHASE_YELLOW;
    case PHASE_MASK_GREEN:
        return TRAFFIC_PHASE_GREEN;
    case PHASE_MASK_RED | PHASE_MASK_YELLOW:
        return TRAFFIC_PHASE_RED_YELLOW;
    default:
        return TRAFFIC_PHASE_CONFLICT;
    }
}

8. Minimum phase duration

Traffic-light phases do not normally change every 100 ms.

c
#define MIN_PHASE_HOLD_US 100000
typedef struct {
    traffic_phase_t current;
    traffic_phase_t candidate;
    int64_t current_since_us;
    int64_t candidate_since_us;
    uint32_t current_mask;
    uint32_t candidate_mask;
    uint32_t phase_change_count;
    uint32_t conflict_count;
} phase_detector_t;

Update:

c
static bool phase_detector_update(phase_detector_t *det,
                                  uint32_t stable_mask,
                                  int64_t now_us,
                                  phase_event_t *out)
{
    traffic_phase_t decoded = decode_phase(stable_mask);
    if (decoded != det->candidate) {
        det->candidate = decoded;
        det->candidate_mask = stable_mask;
        det->candidate_since_us = now_us;
        return false;
    }
    bool candidate_old_enough =
        (now_us - det->candidate_since_us) >= MIN_PHASE_HOLD_US;
    bool conflict = (decoded == TRAFFIC_PHASE_CONFLICT);
    if ((candidate_old_enough || conflict) && decoded != det->current) {
        out->old_phase = det->current;
        out->new_phase = decoded;
        out->detected_us = det->candidate_since_us;
        out->confirmed_us = now_us;
        out->emitted_us = now_us;
        out->stable_mask = stable_mask;
        det->current = decoded;
        det->current_mask = stable_mask;
        det->current_since_us = now_us;
        det->phase_change_count++;
        if (conflict) {
            det->conflict_count++;
        }
        return true;
    }
    return false;
}

9. Avoiding a false OFF between phases

A real transition:

text
RED switches off
GREEN switches on 50 ms later

A naive algorithm may report:

text
RED -> OFF -> GREEN

A rule can be introduced:

text
OFF is considered a separate phase only if it lasts longer than OFF_REPORT_US

For example:

c
#define OFF_REPORT_US 200000

Policy:

text
OFF < 200 ms:
  transitional state, do not send it as a separate phase
OFF >= 200 ms:
  send PHASE_OFF
CONFLICT:
  send promptly: this is fault diagnostics

10. Recommended task structure

text
input_task
  period: 1-5 ms
  reads GPIO raw
  updates ac_channel_update()
  builds stable_mask
  calls phase_detector_update()
  sends phase_event_t to phase_event_queue

An example:

c
#define INPUT_TASK_PERIOD_MS 2
void input_task(void *arg)
{
    phase_event_t ev;
    while (1) {
        int64_t now_us = esp_timer_get_time();
        bool red_raw = gpio_get_level(RED_GPIO) == RED_ACTIVE_LEVEL;
        bool yellow_raw = gpio_get_level(YELLOW_GPIO) == YELLOW_ACTIVE_LEVEL;
        bool green_raw = gpio_get_level(GREEN_GPIO) == GREEN_ACTIVE_LEVEL;
        ac_channel_update(&s_red, red_raw, now_us);
        ac_channel_update(&s_yellow, yellow_raw, now_us);
        ac_channel_update(&s_green, green_raw, now_us);
        uint32_t stable_mask = make_stable_mask(
            s_red.stable,
            s_yellow.stable,
            s_green.stable
        );
        if (phase_detector_update(&s_phase, stable_mask, now_us, &ev)) {
            ev.emitted_us = esp_timer_get_time();
            if (xQueueSend(phase_event_queue, &ev, 0) != pdTRUE) {
                diag.phase_event_queue_drops++;
            }
        }
        vTaskDelay(pdMS_TO_TICKS(INPUT_TASK_PERIOD_MS));
    }
}

11. Common mistakes

  • Reporting OFF too quickly;
  • sending every internal present transition;
  • confusing detected_us with confirmed_us;
  • treating RED+GREEN as a normal state;
  • logging every input_task iteration;
  • assuming a GPIO glitch filter solves AC detection.

12. Practical task

Create PHASE_DETECTOR_POLICY.md:

markdown
# Phase detector policy
## Input model
Raw GPIO level is not a final phase signal.
For AC optocoupler input:
- raw_active means GPIO currently sees active pulse
- present means pulse was seen recently
- stable means present survived confirmation time
## Timing constants
Initial values:
- INPUT_TASK_PERIOD_MS = 2
- AC_PRESENT_TIMEOUT_US = 45000
- AC_STABLE_ON_US = 25000
- AC_STABLE_OFF_US = 70000
- MIN_PHASE_HOLD_US = 100000
- OFF_REPORT_US = 200000
These values must be validated on oscilloscope/HIL.
## Timestamp policy
phase_event_t contains:
- detected_us
- confirmed_us
- emitted_us
Network send time must not replace physical detection time.
## Phase rules
Valid:
- RED
- YELLOW
- GREEN
- RED_YELLOW
- OFF if long enough
Fault:
- RED + GREEN
- YELLOW + GREEN if not expected
- RED + YELLOW + GREEN

HIL scenarios:

text
1. RED pulse train 50/100 Hz for 3 s
   expected: OFF/UNKNOWN -> RED
2. RED pulse train stops for 30 ms and resumes
   expected: no RED -> OFF event
3. RED stops, GREEN starts after 80 ms
   expected: RED -> GREEN or RED -> TRANSITION -> GREEN
4. RED and GREEN pulse together for 300 ms
   expected: CONFLICT event
5. Single YELLOW glitch 5 ms during RED
   expected: no YELLOW phase event
6. No pulses for 1 s
   expected: OFF

13. A short recap

For 220 V AC after an optocoupler, the phase is determined by pulse presence within a time window and confirmation of stability, not by the instantaneous GPIO level. The correct chain:

text
raw GPIO
  -> raw_active
  -> present: a pulse occurred recently
  -> stable: state has been confirmed
  -> stable_mask
  -> decode_phase()
  -> phase_event_t
  -> transport_task

Initial values:

text
INPUT_TASK_PERIOD_MS   = 2 ms
AC_PRESENT_TIMEOUT_US  = 45 ms
AC_STABLE_ON_US        = 25 ms
AC_STABLE_OFF_US       = 70 ms
MIN_PHASE_HOLD_US      = 100 ms
OFF_REPORT_US          = 200 ms

The key point: the phase-change timestamp must be captured before the network, modem, MQTT/TCP/UDP and logs.

The raw GPIO level is inactive between AC pulses, but a pulse was seen recently and the confirmed state remains on. Which value should feed phase_detector in the lesson?

Exercise

A recorded event has detected_us=12000, confirmed_us=92000 and emitted_us=97000. Calculate filter_delay and queue_delay using the lesson’s formulas. Explain why replacing detected_us with the network-send time loses information.

Self-check criteria: Calculate 80 ms and 5 ms, retaining all three timestamps rather than substituting network send time.

Show the supplied answer

filter_delay=92000-12000=80000 µs=80 ms. queue_delay=97000-92000=5000 µs=5 ms. Detection, confirmation and emission describe different stages; a network-send timestamp hides the physical detection time and prevents separating filter delay from queue/network delay.