A lightweight, multi-platform, thread-safe, and glitch-free digital output (relay, LED, valve, buzzer) control library for Arduino. It features an extensible object-oriented architecture allowing you to seamlessly mix local microcontroller GPIO pins and external I2C/SPI port expanders (like the MCP23X17) under a single, unified polymorphic interface.
- Thread-Safe Architecture: Uses a platform-aware
std::timed_muteximplementation on multi-core/RTOS platforms (e.g., ESP32, ARM) while remaining lightweight and fully compatible with legacy single-core, single-thread architectures (AVR, ESP8266). - Glitch-Free Startup: Enforces output voltage level initialization before switching pin modes to output, eliminating unwanted startup spikes or accidental relay triggers.
- Extensible Subclassing: Protected core hardware interfaces (
_init()and_operate()) enable swift integration with alternative hardware interfaces (e.g., I2C expanders, SPI shift registers, or MQTT virtual pins). - Universal Timed Pulses: Supports non-blocking POSITIVE (ON-OFF), NEGATIVE
(OFF-ON), and TOGGLE timed operations using a precise
millis()state machine. - Manual Override Safety: Any explicit state-changing call automatically terminates any active background pulse timer (unless force is false).
Install via Library Manager:
Search for DigitalOutput in the Library Manager (Sketch → Include Library → Manage Libraries).
Install manually:
- Download this repository as a ZIP file
- In Arduino IDE: Sketch → Include Library → Add .ZIP Library
Install via platformio.ini:
Add the library to your platformio.ini:
lib_deps =
adafruit/Adafruit MCP23017 Arduino Library
soosp/DigitalOutputInstall manually:
- Download this repository as a ZIP file
- Extract it into your project's
lib/directory.
Place the header files (*.h) from the src/ directory of this library
inside your sketch folder.
DigitalOutput(int pin, bool active_low = true)Creates a local-GPIO output bound to pin. The constructor only stores
configuration; no pin mode or hardware level is applied until begin() is
called. active_low selects the electrical polarity of the "ON" logical
state:
active_low = true(default) —State::ONdrives the pinLOW. This matches most common relay and opto-isolator modules.active_low = false—State::ONdrives the pinHIGH, e.g. for a directly-driven LED or an active-high solid-state relay.
-
bool begin(State state = State::OFF)Initializes the hardware safely: the output level is written before the pin mode is switched toOUTPUT, so no relay chatter or LED flash occurs on boot. Also clears any leftover pulse state. Returnsfalseif the object mutex could not be acquired withinDIGITAL_OUTPUT_MUTEX_TIMEOUT, or if the hardware initialization itself failed (e.g. a shared expander bus lock timed out). -
bool on(bool force = true)/bool off(bool force = true)/bool toggle(bool force = true)Immediately drive the output to the requested state, bypassing any timed pulse in progress.forcecontrols what happens if apulse()is currently active:force = true(default) — the active pulse is cancelled and the new state is applied immediately.force = false— the call is rejected (no hardware change) while a pulse is running; it only takes effect once the pulse has finished. All three returntrueon success andfalseif rejected byforce, if the object mutex timed out, or if the underlying hardware write failed (e.g. a shared expander bus lock timed out). In the last case the cached state is rolled back, so afalsereturn always means the output was left unchanged — these causes are not distinguishable from the return value alone.
-
bool pulse(uint32_t interval, PulseType type = PulseType::POSITIVE, bool force = true)Starts a non-blocking timed pulse ofintervalmilliseconds.forceworks the same way as above, but applies to an already-running pulse instead of a steady state: withforce = false, callingpulse()again while one is still active is rejected and the original pulse keeps running untouched. This "poll until accepted" pattern (see theMorseexample) is a convenient way to serialize consecutive pulses without manual timing code. When aTOGGLEpulse is triggered while another pulse is already in progress, it inverts from the original baseline state (the one saved before the first pulse started), not from the transient in-pulse state — so back-to-back pulses never drift away from the intended resting state. Returnsfalse(and starts no pulse) if rejected byforce, if the object mutex timed out, or if the initial hardware write failed; afalsereturn always leaves any prior pulse and the output state untouched. -
void update()Must be called periodically (typically once perloop()iteration) forpulse()to take effect — it is the only place where an expired pulse is detected and reverted to its baseline state. Cheap to call when no pulse is active (a lock-free flag check short-circuits immediately). See "When to use update()" below for when it can be omitted entirely. -
State getState()Returns the current logical state, which isState::ONorState::OFF. Note that while a pulse is active, this reflects the transient pulsed state, not the baseline it will return to — e.g. during aNEGATIVEpulse,getState()returnsState::OFFeven though the output will revert toState::ONonce the pulse expires. If you need the resting baseline instead, usegetBaseline(). If the mutex times out, the last cached value is returned without waiting, which may be momentarily stale under heavy contention. -
State getBaseline()Returns the resting state the output will settle into once any active pulse expires, unaffected by a pulse currently in progress. When no pulse is active,getBaseline()always equalsgetState(). Useful for UI/status reporting (e.g. MQTT or Home Assistant state topics) and persisting state across reboots, where a brief pulse shouldn't be reported or saved as a real state change. Subject to the same mutex timeout caveat asgetState(). -
bool isPulsing()Returnstruewhile a timed pulse is active. Note it may briefly readtruefor a pulse whose interval has already elapsed but whichupdate()has not yet retired; if you need "is there meaningful time left" instead, testremaining() > 0. -
uint32_t remaining()Returns the milliseconds left in the active pulse — handy for progress bars or countdown displays. Returns0when no pulse is active, and also0once the interval has elapsed butupdate()has not yet reverted the output (it never returns a spuriously large value). -
Status getStatus()Returns a coherent snapshot of all four status fields at once —state,baseline,pulsing, andremaining— read under a single lock acquisition:struct Status { State state; // current logical state (transient during pulse) State baseline; // resting state the output reverts to bool pulsing; // true if a pulse is active uint32_t remaining; // milliseconds left in the pulse, or 0 };
Prefer this over calling
getState(),getBaseline(),isPulsing(), andremaining()back-to-back: each of those takes and releases the lock separately, so a pulse expiring between calls can yield an inconsistent mix (e.g.pulsing == truealongsideremaining == 0from a slightly later instant).getStatus()guarantees every field describes the same moment. See thePulseProgressexample for a snapshot-driven progress bar.
McpDigitalOutput is a child class to support digital oputputs on MCP23X08
and MCP23X17 I2C/SPI GPIO expander chips. It inherits every method above
unchanged — all behave identically regardless of whether the output is a local
pin or an expander pin. Only construction and the internal
_init()/_operate() hardware calls differ.
// Multi-core/RTOS platforms (ESP32, ARM, ...)
McpDigitalOutput(Adafruit_MCP23X17 &mcp, int pin,
std::timed_mutex &shared_mutex, bool active_low = true)
// Single-core legacy platforms (AVR, ESP8266)
McpDigitalOutput(Adafruit_MCP23X17 &mcp, int pin, bool active_low = true)pin is the expander pin number (0–15), not a microcontroller GPIO. On
RTOS platforms, shared_mutex must be a std::timed_mutex shared by
every McpDigitalOutput on the same physical bus — not merely the
same chip. The actual non-thread-safe resource is the underlying
Wire/TwoWire (or SPI) instance itself: its transaction sequence
(beginTransmission() / write() / endTransmission()) is not atomic
across concurrent callers, so two chips sharing one I2C bus at different
addresses can still corrupt each other's transactions if they use separate
mutexes. Only chips on genuinely independent hardware buses (e.g. an
ESP32's Wire and Wire1, backed by separate I2C peripherals) may use
separate mutex instances, since those buses can run fully in parallel.
std::timed_mutex bus0_mutex; // shared by ALL chips on Wire (bus 0)
std::timed_mutex bus1_mutex; // shared by ALL chips on Wire1 (bus 1), if used
Adafruit_MCP23X17 mcpA, mcpB; // both on Wire, address 0x20 and 0x21
McpDigitalOutput out1(mcpA, 0, bus0_mutex, true);
McpDigitalOutput out2(mcpB, 0, bus0_mutex, true); // same mutex as out1!enum class State : uint8_t { ON, OFF };
enum class PulseType : uint8_t {
POSITIVE, // Forces ON, reverts to OFF after expiration
NEGATIVE, // Forces OFF, reverts to ON after expiration
TOGGLE // Inverts state, reverts to original baseline after expiration
};The update() method is ONLY required if your code uses the timed pulse()
function. If your application only relies on immediate on(), off(), and
toggle() methods, you can completely omit calling update() in your main
loop to save valuable CPU cycles.
On RTOS-supported platforms (e.g. ESP32) every state-changing call acquires a
std::timed_mutex. To stop a hung hardware bus from blocking a task forever,
the lock uses a bounded wait whose length is set by the
DIGITAL_OUTPUT_MUTEX_TIMEOUT macro (milliseconds, default 1000):
#define DIGITAL_OUTPUT_MUTEX_TIMEOUT 1000 // Timeout in millisecondsOverriding it correctly. The macro is consumed inside the header's inline
_lock(), so a #define placed in a single .cpp/.ino before including
DigitalOutput.h changes that one translation unit only. In a project with
more than one translation unit this is not merely incomplete — giving the
inline _lock() different values in different units is an ODR violation.
Define the override once, globally, so every translation unit sees the
same value:
-
PlatformIO — add a build flag in
platformio.ini:build_flags = -D DIGITAL_OUTPUT_MUTEX_TIMEOUT=2000
-
Arduino IDE — ESP32 Core; create a
build_opt.hfile next to your main.ino. The ESP32 Arduino Core passesbuild_opt.hto every translation unit — including the library — via gcc's @file mechanism, so the value stays consistent everywhere. It holds compiler flags, not #defines (despite the .h name), and has no comment support, so keep it to bare -D flags:-DDIGITAL_OUTPUT_MUTEX_TIMEOUT=2000This works identically under arduino-cli (the file just lives in the sketch folder).
NEVER call state-changing methods (on(), off(), toggle() and pulse())
directly inside an Interrupt Service Routine (ISR). Doing so will bypass thread
safety and will cause a deadlock/system crash on RTOS. Always use a deferred
flag pattern (see example below).
The internal std::timed_mutex is not recursive. _init() and
_operate() are always invoked while this mutex is already held by the
calling public method (begin(), on(), off(), toggle(), or
pulse()). If a custom override of _init() or _operate() calls back
into any state-changing method on the same object, the lock attempt will
simply time out after DIGITAL_OUTPUT_MUTEX_TIMEOUT and fail silently
(returning false) rather than deadlock — but this is almost never the
intended behavior. Keep custom _init()/_operate() overrides limited to
direct hardware I/O, as McpDigitalOutput does.
Both hooks return bool: return true when the hardware write succeeds and
false when it cannot be performed (for example, when a shared bus lock times
out). The base class uses this to keep its cached state honest — if an override
returns false, the calling method rolls back the state change it was about to
apply and itself returns false, so getState() never reports a level that was
never written. An override that always succeeds (like a direct GPIO write)
should simply return true.
Each physical pin (or expander channel) must be owned by exactly one
DigitalOutput / McpDigitalOutput instance. The per-object mutex serializes
access within an instance, but two instances bound to the same pin are not
synchronized with each other: their cached states will diverge and their writes
will race. If multiple tasks must drive one output, share a single instance
between them (its mutex makes that safe), rather than constructing one per task.
A straightforward implementation for single, localized hardware control.
#include <Arduino.h>
#include <DigitalOutput.h>
DigitalOutput light(7); // Pin 7, Active LOW by default
void setup() {
light.begin(); // Initializes as OFF
}
void loop() {
light.on();
delay(1000);
light.off();
delay(1000);
}The recommended way to process asynchronous input triggers (e.g., buttons, sensors) safely alongside the library.
#include <Arduino.h>
#include <DigitalOutput.h>
const int buttonPin = 2;
DigitalOutput light(7);
volatile bool buttonPressed = false;
void IRAM_ATTR buttonISR() {
buttonPressed = true; // Minimum footprint ISR
}
void setup() {
light.begin();
pinMode(buttonPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(buttonPin), buttonISR, FALLING);
}
void loop() {
if (buttonPressed) {
buttonPressed = false; // Clear flag outside ISR context
light.toggle(); // Safe execution
}
}Iterating through a synchronized pool containing both native microcontroller pins and an I2C MCP23017 expander chip.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_MCP23X17.h>
#include <DigitalOutput.h>
#include <McpDigitalOutput.h>
Adafruit_MCP23X17 mcp;
#if !defined(ARDUINO_ARCH_AVR) && !defined(ARDUINO_ARCH_ESP8266)
std::timed_mutex bus0_mutex; // Shared per-bus mutex (Wire), not per-chip
#endif
DigitalOutput localRelay(7, true);
#if !defined(ARDUINO_ARCH_AVR) && !defined(ARDUINO_ARCH_ESP8266)
McpDigitalOutput mcpRelay1(mcp, 0, bus0_mutex, true); // Expander pin 0
McpDigitalOutput mcpRelay2(mcp, 1, bus0_mutex, true); // Expander pin 1
#else
McpDigitalOutput mcpRelay1(mcp, 0, true);
McpDigitalOutput mcpRelay2(mcp, 1, true);
#endif
const uint8_t DEVICE_COUNT = 3;
DigitalOutput* outputSystem[DEVICE_COUNT] = {
&localRelay,
&mcpRelay1,
&mcpRelay2
};
void setup() {
Wire.begin();
mcp.begin_I2C(0x20);
for (uint8_t i = 0; i < DEVICE_COUNT; i++) {
outputSystem[i]->begin(DigitalOutput::State::OFF);
}
}
void loop() {
// Efficiently update background pulse timers for all registered devices
for (uint8_t i = 0; i < DEVICE_COUNT; i++) {
outputSystem[i]->update();
}
// Triggering a non-blocking pulse on an I2C device
static uint32_t lastTrigger = 0;
if (millis() - lastTrigger > 10000) {
lastTrigger = millis();
mcpRelay1.pulse(3000, DigitalOutput::PulseType::POSITIVE); // ON for 3s
}
}MIT. See LICENSE.