How OXSwitchManager Works

A walkthrough of the debounce + long-press engine

What this page is

The API reference tells you what each method does. This page explains how the class debounces a noisy mechanical contact and detects a long press, and how the injected input keeps it host-testable. Read it before changing the timing or adding a pattern (double-press, etc.).

The injected input

Like the other managers, the hardware sits behind a tiny interface, IGpioInput (configure(pin, pullup) + read(pin)). The device backend EspGpioInput wraps pinMode()/digitalRead(); host tests inject a FakeGpioInput whose levels they script. So the state machine — the part with the bugs — runs in a desktop unit test with no GPIO, while the actual pin reads come from a one-line wrapper.

OXSwitchManager   (portable: debounce, long-press timer, callbacks)
   │  one injected IGpioInput
   ▼
IGpioInput        configure(pin, pullup) · read(pin) -> HIGH/LOW
   ├─ EspGpioInput   src/gpio/EspGpioInput.cpp  (device)
   └─ FakeGpioInput  test/...                    (host)

Polarity: raw level → "pressed"

A switch to GND reads LOW when pressed; a switch to 3V3 reads HIGH. The manager normalizes both to a logical "pressed" using the config's activeHigh flag, so the rest of the engine never thinks about wiring:

static bool rawPressed(const SwitchConfig &cfg, bool level) {
  return cfg.activeHigh ? level : !level;
}

addSwitch() also seeds the debounced state from the pin's current level, so a switch already held at power-on doesn't produce a phantom press→release on the first poll.

Debounce: require a stable level

Mechanical contacts bounce for a few milliseconds. The engine tracks when the raw reading last changed and only accepts a new debounced state once the level has been stable for debounceMs (default 30 ms):

if (raw != s.lastRawPressed) {     // raw flipped — restart the stability timer
  s.lastRawPressed = raw;
  s.lastChangeMs = nowMs;
}
bool stable = (nowMs - s.lastChangeMs) >= s.cfg.debounceMs;
if (stable && raw != s.pressed) {    // debounced transition
  s.pressed = raw;
  if (s.pressed) { s.pressStartMs = nowMs; s.longFired = false; }
  if (stateCb_) stateCb_(s.cfg.pin, s.pressed, stateCtx_);
}

A glitch shorter than debounceMs never satisfies the stability check, so it's ignored — a test scripts exactly this (press for 10 ms, then release) and asserts no state change fires.

Long press: fire once at the threshold

When a debounced press begins, pressStartMs is stamped and a longFired latch is cleared. Each poll checks the held duration and fires the long-press callback exactly once, the moment it crosses longPressMs — it does not wait for release, so the action triggers at the threshold (e.g. exactly 5 s):

if (s.pressed && !s.longFired &&
    (nowMs - s.pressStartMs) >= s.cfg.longPressMs) {
  s.longFired = true;                 // latch: don't repeat while held
  if (longCb_) longCb_(s.cfg.pin, longCtx_);
}

The latch is what makes it fire once per press rather than every poll after the threshold; it resets on the next fresh press. Tests cover all the edges: fires at the threshold, fires only once while held, and a short press never long-fires.

Cooperative polling

There's no interrupt or RTOS task: you call poll(millis()) from loop() and all timing derives from the timestamp you pass. That keeps the engine deterministic and trivially testable (feed it times), and matches the cooperative style of the other OX managers. The trade-off — you must pump it often enough that a press isn't missed — is fine at the ~10 ms loop cadence the examples use.

Where it fits

OXSwitchManager is the detection layer; OXConfigManager is its first consumer, routing GPIO5's long press to a reset. Any feature needing a button — a record toggle, a pairing trigger — can register its own switch and callback without touching this engine.