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.