What you will learn
- Connect two NPN stages to IN1 and IN2 of a suitable active-LOW module.
- Represent the four channel combinations with a two-bit mask.
- Toggle channels with independent A and B button events.
- Prioritise ALL_OFF and discard commands made while it is active.
- Verify power for both coils and distinguish a software off command from a hardware emergency stop.
Before you start
Courses 16 and 20: relay contacts, transistor input stages, multiple buttons and priority for an off command.
Reference setup
- Board in Arduino IDE
- ESP32 Dev Module
- Arduino-ESP32
- 3.3.12
- Serial Monitor
- 115200 baud
This is a reference profile, not an identification of the pictured board. Adapt the GPIO mapping and verify the circuit before using ESP32-C3/S2/S3 or a differently labelled board.
What we will build
We will control two independent relay channels. Button A changes the first channel, button B changes the second, and a third button requests all channels off. The first relay's contacts switch a red LED and the second switch a green LED, both supplied from 3.3 V. This produces four visible states: both off, only the first on, only the second on and both on.
The exercise extends the previous single-relay project. We now need to consider two output commands, button priority and the supply current for two coils. The aim is to build an understandable control model and check it with deliberately chosen operating cases.
What you will learn
- Connect two transistor input stages to an active-LOW relay module.
- Separate a channel's logical state from the electrical level at its IN terminal.
- Represent two outputs with bits and calculate the final state before writing it.
- Give a shared off command priority over the other commands.
- Verify the supply budget for energising both coils together.
Prerequisites
Complete courses 16 and 20. You need to understand COM, NO and NC, NPN base/collector/emitter connections, INPUT_PULLUP buttons and one event per press. This circuit uses the classic ESP32/WROOM32; it does not automatically transfer to C3, S2 or S3 boards.
Required equipment
| Component | Quantity | Specification and notes |
|---|---|---|
| Development board with a classic ESP32-WROOM-32 module | 1 | Match the reference GPIO labels; check physical header positions on the actual board. |
| USB data and power cable | 1 | Use the connector fitted to your board; the pictured kit lists Micro-USB. |
| Solderless breadboard | 1 | The kit lists 830 tie points. Check whether the power rails are split. |
| 5 V two-channel relay module — active LOW | 1 | Both channels of the reference 5 V active-LOW module are used, with input pull-ups, onboard coil drivers and coil diodes. The current budget includes both coils and the board; verify the actual module specifications. |
| Additional PN2222A NPN transistor | 2Required extra — not in kit photo | ADDITIONAL, not listed in the kit. Identify B/C/E for the exact manufacturer; PN2222A and P2N2222A may have different lead orders. ADDITIONAL: the transistor is not listed in the kit photograph. |
| 1 kΩ resistor | 2 | R1 and R3 limit Q1 and Q2 base current; 1 kΩ for each transistor. |
| 10 kΩ resistor | 2 | R2 and R4 pull Q1 and Q2 bases down to GND; one resistor per base. |
| 220 Ω resistor | 2 | One current-limiting resistor per LED branch; use the kit’s 220 Ω resistors. |
| Red LED | 1 | Anode A and cathode K; identify polarity on the actual part. |
| Green LED | 1 | Anode A and cathode K. |
| Momentary pushbutton | 3 | Use two terminals connected only when pressed; two legs on the same side may already be joined. |
| Jumper wires | 26 | Use male-to-male or female-to-male leads to suit the board headers. Approximate quantity; depends on layout and connector types. |
Two additional PN2222A transistors are required; they are not listed in the photographed kit. Check B/C/E in the manufacturer's documentation for your actual parts. Similar names such as PN2222A and P2N2222A do not establish an identical physical lead order.
Prepare two resistors of each value: 1 kΩ, 10 kΩ and 220 Ω. The kit photograph does not confirm the available quantity of each resistance. The reference two-channel module must use 5 V, active-LOW inputs with pull-ups, onboard coil drivers and coil protection diodes. Q1 and Q2 drive those module inputs; they are not a complete bare-relay driver circuit in this exercise.
Two channels, one priority and the supply budget
Both channels use the same chain: GPIO HIGH switches on the NPN, the NPN pulls its IN terminal LOW, and the active-LOW module energises the relay. GPIO LOW turns the transistor off, allowing the input pull-up to bring IN HIGH. The program can therefore use the simple logical rule “one means on”.
| Channel 1 | Channel 2 | Red contact LED | Green contact LED |
|---|---|---|---|
| 0 | 0 | Off. | Off. |
| 1 | 0 | On. | Off. |
| 0 | 1 | Off. | On. |
| 1 | 1 | On. | On. |
Before energising both coils, verify the total current from the module documentation and the capability of the USB source, cable and actual development board. Include the ESP32, input circuits, both relay coils and indicators. A 15–20 mA control-input figure is not the total consumption of the two-channel module. If the complete budget is not established, do not test state 1/1 until the supply arrangement has been verified.
ALL_OFF is a prioritised software command. It is not a certified hardware emergency stop: it depends on program execution, power and functioning relay hardware. In this exercise, the contacts switch only the 3.3 V LED circuits. Do not attach a motor, heater or mains voltage.
Wiring and connection order
Wiring diagram
Each relay input has its own NPN driver. GPIO HIGH turns the transistor on and pulls IN LOW. Two additional PN2222A parts are required; the module already has coil drivers and diodes. COM/NO contacts switch only two 3.3 V LED branches. Verify USB5V current capacity for the board and both energized coils.
| From | To | Connection |
|---|---|---|
ESP32.GPIO25 | R1.1 | R1, 1 kΩ, limits base current. |
R1.2 | Q1.B | Base of the additional PN2222A; verify physical B/C/E lead order. |
Q1.B | R2.1 | R2, 10 kΩ, pulls the base down. |
R2.2 | ESP32.GND | Other end of the base pull-down resistor. |
Q1.E | ESP32.GND | Emitter to common ground. |
ESP32.GPIO26 | R3.1 | R3, 1 kΩ, limits base current. |
R3.2 | Q2.B | Base of the additional PN2222A; verify physical B/C/E lead order. |
Q2.B | R4.1 | R4, 10 kΩ, pulls the base down. |
R4.2 | ESP32.GND | Other end of the base pull-down resistor. |
Q2.E | ESP32.GND | Emitter to common ground. |
Q1.C | K1.IN1 | Q1 pulls IN1 LOW to energize channel 1. |
Q2.C | K1.IN2 | Q2 pulls IN2 LOW to energize channel 2; both inputs are now used. |
ESP32.USB5V | K1.VCC | Verified 5 V rail with adequate current for both channels; documented JD-VCC jumper if present. |
ESP32.GND | K1.GND | Common ground for the module and both input-driver transistors. |
ESP32.3V3 | K1.COM1 | Channel 1 common contact: only the 3.3 V LED circuit. |
K1.NO1 | R5.1 | Normally open contact to R5, 220 Ω. |
R5.2 | D1.A | Resistor to the LED anode. |
D1.K | ESP32.GND | Cathode to ground; leave NC1 unconnected. |
ESP32.3V3 | K1.COM2 | Channel 2 common contact: only the 3.3 V LED circuit. |
K1.NO2 | R6.1 | Normally open contact to R6, 220 Ω. |
R6.2 | D2.A | Resistor to the LED anode. |
D2.K | ESP32.GND | Cathode to ground; leave NC2 unconnected. |
ESP32.GPIO27 | SW1.CONTACT_A | A button; INPUT_PULLUP input. |
SW1.CONTACT_B | ESP32.GND | The contact pair that connects only while pressed. |
ESP32.GPIO32 | SW2.CONTACT_A | B button; INPUT_PULLUP input. |
SW2.CONTACT_B | ESP32.GND | The contact pair that connects only while pressed. |
ESP32.GPIO33 | SW3.CONTACT_A | ALL_OFF button; INPUT_PULLUP input. |
SW3.CONTACT_B | ESP32.GND | The contact pair that connects only while pressed. |
- Disconnect USB. Verify the module, both transistor pinouts and the supply budget. Identify COM1/NO1/NC1 and COM2/NO2/NC2 from the module labels.
- Join ESP32 GND, K1 GND and the emitters of Q1 and Q2 to common ground.
- Connect GPIO25 through R1, 1 kΩ, to Q1 base. Connect R2, 10 kΩ, from Q1 base to GND. Connect Q1 collector to K1 IN1.
- Connect GPIO26 through R3, 1 kΩ, to Q2 base. Connect R4, 10 kΩ, from Q2 base to GND. Connect Q2 collector to K1 IN2.
- Connect module VCC to the documented USB-derived 5 V rail on the board. If the module provides JD-VCC, use its documented connection with the same 5 V source.
- Connect 3V3 to COM1 and COM2. Connect NO1 through R5, 220 Ω, to the red D1 anode and NO2 through R6, 220 Ω, to the green D2 anode. Connect both cathodes to GND; leave both NC contacts unconnected.
- Connect SW1/A between GPIO27 and GND, SW2/B between GPIO32 and GND, and SW3/ALL_OFF between GPIO33 and GND. Verify the terminal pairs that close only when pressed.
- Recheck the wiring and resistors, then connect USB. Both relays and both contact LEDs should start off.
IN2 is no longer tied to 5 V as an unused channel, unlike course 16. Q2 collector controls it here. Neither IN1 nor IN2 connects directly to a GPIO. We do not place an extra diode across an input: the appropriate module must already provide coil protection. Do not parallel an independent 5 V supply with USB power.
Preparing the Arduino environment
Use Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, ESP32 Dev Module and the correct port. No third-party libraries are required. Open dual_relay/dual_relay.ino, confirm GPIO25/26 for the outputs and GPIO27/32/33 for the buttons, then choose Verify and Upload. Set Serial Monitor to 115200 baud.
Serial output reports changes in the logical states. The contact LEDs separately check the path through COM and NO. A relay click or the module's onboard indicator does not by itself prove that you connected the external LED circuit to the correct contacts.
Complete Arduino program
#include <Arduino.h>
constexpr uint8_t RELAY_1_PIN = 25, RELAY_2_PIN = 26;
constexpr uint8_t BUTTON_A_PIN = 27, BUTTON_B_PIN = 32, ALL_OFF_PIN = 33;
constexpr uint32_t DEBOUNCE_MS = 30;
// A fresh event needs 30 ms at one level. A held or inhibited press must
// release stably before another event can be accepted.
struct DebouncedButton {
uint8_t pin;
bool rawHigh = true, stableHigh = true, armed = true;
bool pressed = false, released = false;
uint32_t rawChangedAt = 0;
explicit DebouncedButton(uint8_t inputPin) : pin(inputPin) {}
void begin(uint32_t now) {
pinMode(pin, INPUT_PULLUP);
rawHigh = stableHigh = digitalRead(pin) == HIGH;
armed = rawHigh;
rawChangedAt = now;
pressed = released = false;
}
void update(uint32_t now) {
pressed = released = false;
const bool readingHigh = digitalRead(pin) == HIGH;
if (readingHigh != rawHigh) {
rawHigh = readingHigh;
rawChangedAt = now;
}
if (static_cast<uint32_t>(now - rawChangedAt) < DEBOUNCE_MS) return;
if (rawHigh != stableHigh) {
stableHigh = rawHigh;
if (stableHigh) released = true;
else if (armed) {
pressed = true;
armed = false;
}
}
// Also recovers from an inhibited LOW pulse too short to become stable.
if (rawHigh && stableHigh) armed = true;
}
void inhibit() { armed = false; pressed = false; }
bool isReleased(uint32_t now) const {
return rawHigh && stableHigh &&
static_cast<uint32_t>(now - rawChangedAt) >= DEBOUNCE_MS;
}
};
DebouncedButton buttonA(BUTTON_A_PIN), buttonB(BUTTON_B_PIN), offButton(ALL_OFF_PIN);
uint8_t channelMask = 0;
void applyChannels(uint8_t nextMask) {
channelMask = nextMask & 0x03;
// GPIO HIGH -> NPN ON -> module IN LOW -> relay ON.
digitalWrite(RELAY_1_PIN, channelMask & 0x01 ? HIGH : LOW);
digitalWrite(RELAY_2_PIN, channelMask & 0x02 ? HIGH : LOW);
Serial.print("Relay 1="); Serial.print(channelMask & 0x01 ? "ON" : "OFF");
Serial.print(" Relay 2="); Serial.println(channelMask & 0x02 ? "ON" : "OFF");
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_1_PIN, OUTPUT); pinMode(RELAY_2_PIN, OUTPUT);
digitalWrite(RELAY_1_PIN, LOW); digitalWrite(RELAY_2_PIN, LOW);
const uint32_t now = millis();
buttonA.begin(now); buttonB.begin(now); offButton.begin(now);
applyChannels(0);
}
void loop() {
const uint32_t now = millis();
buttonA.update(now); buttonB.update(now); offButton.update(now);
uint8_t nextMask = channelMask;
if (offButton.pressed) nextMask = 0;
// Raw LOW blocks new toggles immediately; a qualified OFF press clears
// both outputs. Stable LOW keeps the block through release debounce.
const bool offInhibits = !offButton.rawHigh || !offButton.stableHigh;
if (offInhibits || offButton.pressed) {
buttonA.inhibit();
buttonB.inhibit();
} else {
if (buttonA.pressed) nextMask ^= 0x01;
if (buttonB.pressed) nextMask ^= 0x02;
}
// Compute the complete mask before changing outputs. GPIO writes are still
// sequential; this is an LED demonstration, not a hardware interlock.
if (nextMask != channelMask) applyChannels(nextMask);
}
Begin with the unchanged sketch and clearly labelled buttons. Do not copy polarity assumptions from a directly controlled relay input: the additional NPN reverses the relationship between GPIO and IN levels here.
How the program works
channelMask stores the two channel states. Bit 0 represents the first channel and bit 1 the second. The values are therefore 0 for both off, 1 for only the first on, 2 for only the second on and 3 for both on. A bitwise XOR with mask 1 or 2 toggles only its selected channel.
The three independently processed buttons use INPUT_PULLUP and 30 ms stability. A fresh press creates one event; holding a button does not repeatedly toggle an output. A button held during reset must first be released before a new command can be accepted.
A qualified fresh ALL_OFF press, after 30 ms stability, sets nextMask to zero. An earlier raw LOW already inhibits A and B commands, and stable LOW maintains that inhibition through release qualification. Presses made during this inhibition are not queued for later. An A or B button must subsequently be released and pressed again before it can enable a channel.
Without ALL_OFF inhibition, A and B events accepted in the same loop iteration toggle both channels. The program calculates the final mask first and then passes it to applyChannels(). This avoids creating an extra intermediate logical state because of processing order. The physical GPIO writes still happen sequentially; this does not promise that both mechanical contacts move in precisely the same microsecond.
Experiment and expected results
| Action | Expected channel 1 / channel 2 state |
|---|---|
| Reset with all buttons released. | 0 / 0. |
| Press A, release it, then press B. | First 1 / 0, then 1 / 1. |
| From 1 / 1, make a fresh A press. | 0 / 1; the second channel remains on. |
| Press ALL_OFF. | 0 / 0. |
| Hold ALL_OFF while pressing A and B. | Both stay off; releasing does not trigger a queued command. |
| From 0 / 0, accept A and B in the same iteration without ALL_OFF. | 1 / 1. |
| Reset while holding A. | 0 / 0; release and a fresh press are required to turn it on. |
Record Serial output and both contact LEDs. Two physical presses made “at the same time” are not necessarily qualified in the same iteration; they can appear as two successive valid changes. Perform the 1/1 check only after confirming adequate power for both coils.
Troubleshooting
| Symptom | Check and corrective action |
|---|---|
| The second channel never works. | Check GPIO26, Q2 B/C/E and IN2; remove the old IN2-to-5 V connection from the earlier exercise. |
| An LED lights when the program reports OFF. | Disconnect USB and check whether you used NC instead of NO. |
| ESP32 resets when both channels turn on. | Disconnect the circuit and check total current, the USB supply path and connections; do not assume a software cause. |
| A held button causes repeated changes. | Confirm you are using the canonical sketch with debouncing and fresh-press events. |
| A and B do not respond after ALL_OFF. | Release every button before trying again; check that the ALL_OFF input is not permanently grounded. |
Independent challenges
First write the mask sequence for A, B, A, B, starting at zero and releasing between presses. Then make a separate version that permits only one channel on: enabling one switches off the other. Decide in advance what should happen when A and B are accepted together, while retaining ALL_OFF as the highest priority.
Worked challenge solutions
The original sequence produces 0 → 1 → 3 → 2 → 0. After the second press both channels are on because the original program has no mutual exclusion. Each XOR changes only its own bit.
For the alternative version, choose “A takes priority over B when they arrive together”. Keep the ALL_OFF handling, offInhibits and the inhibit() calls. Replace only the two XOR conditions inside the existing else branch with:
if (buttonA.pressed) {
nextMask = (channelMask & 0x01) ? 0 : 1;
} else if (buttonB.pressed) {
nextMask = (channelMask & 0x02) ? 0 : 2;
}
Keep the existing final applyChannels() call when the mask changes. An already-on selected channel turns off; otherwise only that channel turns on.
For example, B changes mask 1 to 2; another B changes 2 to 0; simultaneous A/B from 0 gives 1. This changes the rules of the LED demonstration. Software mutual exclusion alone is not a protective circuit for reversing a motor.
Knowledge check with answers
Why does GPIO HIGH enable an active-LOW module? The GPIO enables the transistor, whose collector pulls IN LOW. “Active LOW” describes IN, not the GPIO before the transistor.
Why not put a diode across IN1? IN1 is a control input. The diode that handles stored coil energy belongs in the module's coil driver circuit and must already be provided as documented.
Does ALL_OFF disconnect power from the device? No. It requests off outputs while the ESP32 and relay module remain powered. Why is the total not simply twice 15–20 mA? A control-input figure does not necessarily include either coil or the remaining circuitry.
Primary sources
- SunFounder: reference 5 V active-LOW two-channel module.
- SunFounder: relays and COM/NO/NC contacts.
- onsemi: PN2222A datasheet.
- onsemi: P2N2222A datasheet for pinout comparison.
- Espressif: ESP32-DevKitC and power supply.
- Espressif: GPIO API.
Downloads
Download the sketch and the two-channel diagram. Record the actual ESP32 compilation and physical test separately; the expected behaviour in this lesson does not certify that your module, board and supply have already been tested.
