If you are building a raspberry pi with node js for hardware control in 2026, the default recommendation is to use a Raspberry Pi 4 Model B (4GB) running Raspberry Pi OS Bookworm, paired with the onoff npm package for GPIO manipulation and express for API routing. While newer libraries exist, onoff remains the most stable pure-JavaScript binding for standard relay switching and button interrupts, provided you bypass the Bookworm sysfs deprecation (detailed below).
This guide walks through building a Node.js-controlled 12V solenoid/relay system with a physical hardware button override. We will cover the library decision matrix, exact pin mappings, the Bookworm OS configuration fix, and full error-handling code.
The Decision Path: Choosing Your Node.js GPIO Library
The Node.js GPIO ecosystem on ARM64 has fractured slightly with the transition from Raspberry Pi OS Bullseye to Bookworm, and the introduction of the Pi 5's RP1 chip. Use this decision tree to select your library.
| Library | Best Use Case | PWM Support | Bookworm / Pi 5 Status |
|---|---|---|---|
onoff | Relays, buttons, basic sensors | Software only (jittery) | Requires manual sysfs enable on Bookworm; fails on Pi 5. |
pigpio | Servos, DC motor speed, LED dimming | Hardware PWM | Requires C-bindings or daemon; robust on Pi 4, tricky on Pi 5. |
rpio | Legacy direct-memory access | Hardware PWM | Abandoned. Fails on modern kernels. |
johnny-five | Multi-board orchestration | Varies by plugin | Overkill for single-board Pi projects. |
onoff. If you need smooth hardware PWM for a servo or DC motor, pick pigpio. For this build, we terminate on onoff because we are driving a relay module and reading a tactile button interrupt.
Hardware BOM and Pin Mapping
This build targets the Raspberry Pi 4 Model B (4GB variant). Do not use a Pi 5 for this specific codebase without swapping to the lgpio Python/C ecosystem, as the RP1 chip breaks standard sysfs memory mapping.
Parts List
- Microcontroller: Raspberry Pi 4 Model B (4GB RAM)
- Relay Module: 5V Single-Channel Optocoupler Relay Module (Active Low)
- Switch: 6x6mm Tactile Pushbutton (Normally Open)
- Resistors: 10kΩ (for button pull-up, if not using internal), 1kΩ (for LED indicator)
- Protection: 1N4007 Flyback Diode (soldered across the relay coil if not built into the module)
- Power: 5V 3A USB-C Power Supply (Pi official)
Pin Mapping Table (BCM Numbering)
| Component | BCM GPIO | Physical Pin | Direction | Notes |
|---|---|---|---|---|
| Relay IN (Signal) | GPIO 17 | 11 | OUTPUT | Active LOW (0V triggers relay) |
| Relay VCC | 5V Power | 2 | Power | Do NOT use 3.3V for 5V relay modules |
| Relay GND | GND | 6 | Ground | Common ground with Pi |
| Tactile Button | GPIO 27 | 13 | INPUT | Internal Pull-UP enabled in code |
| Button GND | GND | 9 | Ground | Connects to other side of switch |
Wiring and OS Configuration Steps
Before writing code, you must configure the operating system. Raspberry Pi OS Bookworm disabled the legacy sysfs GPIO interface by default in favor of libgpiod. The onoff Node.js library relies on sysfs. If you skip this step, your code will crash instantly.
- Wire the Relay: Connect Physical Pin 2 (5V) to Relay VCC, Physical Pin 6 (GND) to Relay GND, and Physical Pin 11 (GPIO 17) to Relay IN.
- Wire the Button: Connect one leg of the tactile button to Physical Pin 13 (GPIO 27) and the other leg to Physical Pin 9 (GND).
- Enable sysfs (CRITICAL): Open your Pi's terminal and edit the config file:
sudo nano /boot/firmware/config.txt
Add this exact line to the bottom of the file:gpio=0-27=ip,ih,pu(Optional: sets internal pull-ups at boot)
More importantly, ensure the legacy interface is exposed by adding:dtparam=gpio_sysfs=on
Note: If your OS is older (Bullseye), this line is not required. - Reboot the Pi:
sudo reboot - Initialize Project: Create a directory, run
npm init -y, and install dependencies:npm install onoff express
The Node.js Control Code
This Express server exposes an API to toggle the relay and listens for a hardware button interrupt to manually override the state. Save this as server.js.
const { Gpio } = require('onoff');
const express = require('express');
const app = express();
const PORT = 3000;
// Pin Definitions (BCM Numbering)
const RELAY_PIN = 17;
const BUTTON_PIN = 27;
// Hardware Initialization
// Relay is active LOW: 0 = ON, 1 = OFF
const relay = new Gpio(RELAY_PIN, 'out');
// Button uses internal pullup: pressing connects to GND, reading 0
const button = new Gpio(BUTTON_PIN, 'in', 'both', { debounceTimeout: 50 });
// Set initial state to OFF (High for active-low relay)
relay.writeSync(1);
let relayState = false;
// Hardware Button Interrupt Handler
button.watch((err, value) => {
if (err) {
console.error('Button interrupt error:', err);
return;
}
// value === 0 means button is pressed (pulled to GND)
if (value === 0) {
relayState = !relayState;
const gpioOut = relayState ? 0 : 1;
relay.writeSync(gpioOut);
console.log(`[Hardware Override] Relay toggled to: ${relayState ? 'ON' : 'OFF'}`);
}
});
// Express API Routes
app.use(express.json());
app.get('/api/status', (req, res) => {
res.json({ relay_active: relayState, timestamp: new Date().toISOString() });
});
app.post('/api/toggle', (req, res) => {
try {
relayState = !relayState;
const gpioOut = relayState ? 0 : 1;
relay.writeSync(gpioOut);
res.json({ success: true, new_state: relayState ? 'ON' : 'OFF' });
} catch (error) {
res.status(500).json({ error: 'GPIO write failed', details: error.message });
}
});
app.post('/api/set', (req, res) => {
const { state } = req.body;
if (typeof state !== 'boolean') {
return res.status(400).json({ error: 'Payload must include boolean "state"' });
}
try {
relayState = state;
relay.writeSync(state ? 0 : 1);
res.json({ success: true, new_state: state ? 'ON' : 'OFF' });
} catch (error) {
res.status(500).json({ error: 'GPIO write failed', details: error.message });
}
});
// Graceful Shutdown to release GPIO pins
process.on('SIGINT', () => {
console.log('\nShutting down: Releasing GPIO pins...');
relay.unexport();
button.unexport();
process.exit(0);
});
app.listen(PORT, () => {
console.log(`Raspberry Pi Node.js API listening on port ${PORT}`);
console.log(`Control Relay: curl -X POST http://localhost:${PORT}/api/toggle`);
});
Run the server using node server.js (or sudo node server.js if you encounter permission errors detailed below).
Debugging: 'EPERM' and Initialization Failures
When running a raspberry pi with node js for the first time on modern OS builds, you will likely hit memory-mapping or permission errors. Here is the exact decision path for the most common crashes.
Error 1: Error: EPERM: operation not permitted, open '/sys/class/gpio/export'
What it means: Node.js is trying to write to the sysfs GPIO directory, but the OS is blocking it due to missing permissions or disabled interfaces.
First 3 things to check:
- Is sysfs enabled? Run
cat /boot/firmware/config.txt | grep sysfs. If it doesn't returndtparam=gpio_sysfs=on, add it and reboot. - Are you running as root? The
gpiogroup permissions on Bookworm are notoriously strict for sysfs. Run your script withsudo node server.jsto test. If it works, fix it permanently by adding your user to the gpio group:sudo usermod -aG gpio $USERand rebooting. - Is the pin already claimed? If you ran the script previously and it crashed without hitting the
SIGINTgraceful shutdown, the pin might still be exported. Runecho 17 > /sys/class/gpio/unexportto force-release it.
Error 2: Error: ENOENT: no such file or directory, open '/sys/class/gpio/gpio17/direction'
What it means: The pin was exported, but the kernel hasn't created the direction file yet, or the pin is physically unavailable.
Ranked Causes:
- Race condition in
onoff: The library tries to set direction before the kernel finishes creating the sysfs node. Fix: Add a 100mssetTimeoutbefore initializing the Gpio object, or update to the latestonoffversion which handles this retry logic. - Wrong Pin Numbering: You used Physical Pin 17 (which is 3.3V power) instead of BCM GPIO 17 (Physical Pin 11). Fix: Verify BCM vs Physical mapping. The code uses BCM.
Extending or Simplifying the Build
Depending on your end goal, you should adjust this baseline architecture rather than starting from scratch.
How to Simplify (Headless Appliance Mode)
If you don't need the Express API and just want the Pi to act as a dumb hardware timer or sensor-triggered relay:
1. Strip out express and the HTTP routes.
2. Replace the API logic with a setInterval or a cron job.
3. Use pm2 (npm install -g pm2) to daemonize the script so it survives reboots without needing systemd configuration.
How to Extend (MQTT and Home Automation)
If you are integrating this into Home Assistant or a larger IoT fleet:
1. Replace the Express REST API with an MQTT client using the mqtt npm package.
2. Subscribe to a topic like home/office/relay/set.
3. Publish state changes to home/office/relay/status whenever the physical button is pressed, ensuring the UI and hardware state never desync.
4. Hardware addition: Add an ACS712 current sensor to the 12V load line and read it via an MCP3008 ADC (using the mcp-spi-adc npm package) to verify the solenoid actually fired, publishing a fault alert if current draw is zero when the relay is closed.






