The Verdict: Nodejs Raspberry Pi Stack for Hardware Control
Running Node.js on a Raspberry Pi for direct hardware control bridges the gap between high-level web frameworks and bare-metal embedded C. For a reliable, production-ready stack in 2026, the optimal configuration is a Raspberry Pi 4 Model B (4GB RAM) running Raspberry Pi OS Bookworm (64-bit), paired with Node.js v20 LTS.
While the Pi 5 is faster, the Pi 4B remains the most stable target for native GPIO C-bindings (like onoff) without requiring the newer libgpiod workarounds. By combining the onoff library for digital output and i2c-bus for sensor communication, you can build a responsive environmental relay controller that polls a BME280 sensor and triggers a 5V relay when temperature thresholds are crossed.
Parts List and Pin Mapping Spec Sheet
Sourcing the exact variants below prevents the most common I2C address conflicts and GPIO voltage mismatches. Total hardware cost is approximately $75.
| Component | Exact Variant / Model | Est. Price | Notes |
|---|---|---|---|
| Microcontroller | Raspberry Pi 4 Model B (4GB) | $55.00 | Target board for this code. Pi 3B+ also works. |
| Sensor | BME280 I2C (Generic or Adafruit 2652) | $10.00 | Ensure it is BME280 (humidity+temp), not BMP280. |
| Actuator | 5V SPDT Relay Module (Songle SRD-05VDC-SL-C) | $4.00 | Must have optocoupler isolation and logic-level trigger. |
| Storage | SanDisk Extreme 32GB microSD (A1 rated) | $12.00 | A1 rating prevents OS lag during Node.js npm installs. |
| Wiring | Female-to-Female Dupont jumpers | $5.00 | Use 20cm length to keep I2C capacitance low. |
Pin Mapping Table
This project uses BCM (Broadcom) pin numbering in the code, but physical pin numbers are listed for your breadboard wiring.
| Pi Physical Pin | BCM GPIO | Function | Connects To |
|---|---|---|---|
| Pin 1 | 3.3V Power | VCC | BME280 VIN / VCC |
| Pin 3 | GPIO 2 | I2C SDA | BME280 SDI / SDA |
| Pin 5 | GPIO 3 | I2C SCL | BME280 SCK / SCL |
| Pin 6 | GND | Ground | BME280 GND |
| Pin 11 | GPIO 17 | Digital Out | Relay Module IN (Signal) |
| Pin 2 | 5V Power | VCC | Relay Module VCC / JD-VCC |
| Pin 9 | GND | Ground | Relay Module GND |
Step-by-Step Build Procedure
Bookworm OS changed how GPIO permissions are handled compared to Bullseye. Follow these exact steps to avoid the dreaded permission denied errors later.
- Flash and Boot: Flash Raspberry Pi OS Bookworm (64-bit, Lite version preferred for headless) using Raspberry Pi Imager. Enable SSH and set your username in the imager settings.
- Enable I2C Interface: SSH into the Pi and run
sudo raspi-config. Navigate to Interface Options > I2C and select Yes to enable the ARM I2C interface. Reboot the Pi. - Fix GPIO Permissions: Bookworm restricts
/sys/class/gpioaccess. Add your user to thegpioandi2cgroups:sudo usermod -aG gpio,i2c $USER
Log out and log back in (or reboot) for group changes to take effect. - Install Node.js v20 LTS: Use the NodeSource setup script for a clean install:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -sudo apt-get install -y nodejs - Install Build Tools: Native modules like
onoffrequire C++ compilation tools.sudo apt-get install -y build-essential python3 - Initialize Project: Create a directory, run
npm init -y, and install the dependencies:npm install onoff i2c-bus bme280-sensor
The Complete Nodejs Raspberry Pi Controller Code
Save the following code as controller.js. This script initializes the I2C bus, reads the BME280 sensor every 5 seconds, and toggles the relay on GPIO 17 if the temperature exceeds 26.0°C (78.8°F). It includes graceful shutdown handling to unexport the GPIO pins when you press Ctrl+C.
const { Gpio } = require('onoff');
const i2c = require('i2c-bus');
const BME280 = require('bme280-sensor');
// --- PIN & CONFIG DEFINITIONS ---
const RELAY_PIN = 17; // BCM GPIO 17 (Physical Pin 11)
const I2C_BUS_NUM = 1; // /dev/i2c-1
const BME_ADDR = 0x76; // 0x76 for generic, 0x77 for Adafruit
const TEMP_THRESHOLD = 26.0; // Celsius
const POLL_INTERVAL = 5000; // 5 seconds
// --- HARDWARE INITIALIZATION ---
// Initialize Relay as Output, starting in LOW (off) state
const relay = new Gpio(RELAY_PIN, 'out');
relay.writeSync(0);
// Initialize I2C Bus
const bus = i2c.openSync(I2C_BUS_NUM);
// Initialize BME280 Sensor
const sensor = new BME280(bus, BME_ADDR);
let isRelayOn = false;
async function startMonitoring() {
try {
await sensor.init();
console.log(`BME280 initialized on I2C bus ${I2C_BUS_NUM} at address 0x${BME_ADDR.toString(16)}`);
console.log(`Monitoring temperature. Threshold: ${TEMP_THRESHOLD}°C`);
setInterval(async () => {
try {
const reading = await sensor.readSensorData();
const tempC = reading.temperature.toFixed(2);
const humidity = reading.humidity.toFixed(1);
console.log(`[Read] Temp: ${tempC}°C | Humidity: ${humidity}%`);
// Decision Logic
if (parseFloat(tempC) >= TEMP_THRESHOLD && !isRelayOn) {
relay.writeSync(1);
isRelayOn = true;
console.log('>> RELAY ENGAGED (Cooling/Exhaust ON)');
} else if (parseFloat(tempC) < (TEMP_THRESHOLD - 1.0) && isRelayOn) {
// 1 degree hysteresis to prevent rapid clicking
relay.writeSync(0);
isRelayOn = false;
console.log('>> RELAY DISENGAGED (Cooling/Exhaust OFF)');
}
} catch (readErr) {
console.error('Sensor read failed:', readErr.message);
}
}, POLL_INTERVAL);
} catch (initErr) {
console.error('Failed to initialize hardware:', initErr.message);
cleanupAndExit(1);
}
}
// --- GRACEFUL SHUTDOWN ---
function cleanupAndExit(code = 0) {
console.log('\nShutting down and unexporting GPIO pins...');
relay.writeSync(0); // Ensure relay is off
relay.unexport();
bus.closeSync();
process.exit(code);
}
process.on('SIGINT', () => cleanupAndExit(0));
process.on('SIGTERM', () => cleanupAndExit(0));
// Start the loop
startMonitoring();
Run the script with: node controller.js
Debugging: First Three Things to Check When It Fails
When bridging JavaScript and physical silicon, errors usually happen at the OS boundary. If your script crashes on startup, check these three things in order.
1. Error: ENOENT: no such file or directory, open '/dev/i2c-1'
- Cause: The I2C kernel module is not loaded, meaning the I2C interface is disabled in the OS.
- Fix: Run
sudo raspi-config, go to Interface Options, and enable I2C. Reboot. Verify withls /dev/i2c*— you should see/dev/i2c-1.
2. Error: EACCES: permission denied, open '/sys/class/gpio/export'
- Cause: Your current Linux user does not have rights to manipulate the GPIO sysfs entries. This is the #1 issue on Bookworm OS.
- Fix: Ensure you ran
sudo usermod -aG gpio $USERand rebooted. If you are using a non-standard OS build, you may need to create a custom udev rule in/etc/udev/rules.d/99-gpio.rulesto set the group ownership of/sys/class/gpiotogpio.
3. Sensor Reads All Zeros or Throws EIO (I/O Error)
- Cause: The Pi cannot communicate with the BME280 over the I2C bus. This is almost always a wiring or pull-up resistor issue.
- Fix: Run
i2cdetect -y 1in the terminal. If you don't see76or77in the grid, swap your SDA and SCL wires. If the grid is entirely empty, your BME280 breakout board lacks pull-up resistors; add 4.7kΩ resistors between SDA/SCL and 3.3V.
Decision Tree: Choosing the Right Node.js GPIO Library
The Node.js ecosystem has several ways to toggle a pin on a Raspberry Pi. Use this decision matrix to pick the right tool for your specific hardware constraints.
| Library | Best For | Drawbacks | Pi 5 Compatible? |
|---|---|---|---|
| onoff | Standard digital I/O, button interrupts, simple relays. | Relies on deprecated sysfs; requires node-gyp compilation. | Partial (Requires sysfs enabled in config.txt) |
| pigpio | Hardware PWM, servo control, high-frequency pulse reading. | Heavy C dependency; daemon must be running or linked. | No (Relies on older BCM2835 memory mapping) |
| child_process | Fallback when native C-bindings fail to compile. | High latency (~20ms); terrible for PWM or fast interrupts. | Yes (Uses native raspi-gpio CLI) |
onoff. It provides the cleanest async/await syntax and handles interrupts natively. Only fall back to child_process calling raspi-gpio if you are on a Pi 5 and cannot get libgpiod bindings to compile.
Extending and Simplifying the Build
Once the baseline controller is running, you can scale the project up or down based on your deployment environment.
How to Simplify (The Bench Test)
If you don't have a BME280 sensor on hand and just want to verify the Node.js to Relay logic, strip out the I2C code. Replace the sensor logic with a physical pushbutton wired between GPIO 27 and GND. Enable the internal pull-up resistor in onoff by initializing it as new Gpio(27, 'in', 'both') and attach an interrupt watcher to toggle the relay on button presses. This removes all I2C dependencies and isolates your GPIO logic.
How to Extend (The Production Upgrade)
To make this a true IoT node, wrap the polling loop in an Express.js web server to serve a real-time dashboard, or use the mqtt npm package to publish the temperature readings to a local Mosquitto broker.
For production deployments, add the pm2 process manager (sudo npm install -g pm2). Run your script with pm2 start controller.js and save the process list (pm2 save). This ensures your Nodejs Raspberry Pi controller automatically restarts if the script crashes or the Pi loses power, bridging the gap between a hobby script and a reliable appliance.






