Using a Raspberry Pi and Node.js for embedded hardware control bridges the gap between high-level web APIs and physical world actuation. Node.js excels at asynchronous I/O, making it ideal for polling sensors, logging to cloud databases, and serving local dashboards without blocking the main thread. However, it requires strict attention to hardware interfaces, voltage levels, and OS-level permissions.
This guide walks through building an environmental monitor that reads a BME280 sensor via I2C and triggers a 3.3V relay based on temperature thresholds. We will cover the exact wiring, provide a production-ready Node.js script, and deep-dive into debugging the most common I2C failure mode on Raspberry Pi OS.
Hardware Spec Sheet & Parts List
Before stripping wires, verify your components. The Raspberry Pi GPIO header operates strictly at 3.3V. Feeding 5V back into a GPIO pin will destroy the SoC. We use a 3.3V relay module to avoid level-shifting complexities.
| Component | Exact Variant / Model | Notes |
|---|---|---|
| Microcontroller | Raspberry Pi 4 Model B (4GB RAM) | Target board. Code also runs on Pi 5 with Bookworm. |
| OS / Runtime | Raspberry Pi OS Bookworm (64-bit) / Node.js v22 LTS | Ensure Node 22+ for native fetch and stable ESM support. |
| Sensor | BME280 I2C Breakout (3.3V) | Adafruit 2652 or generic. Must support I2C (not SPI-only). |
| Actuator | 3.3V 1-Channel Relay Module (SRD-03VDC-SL-C) | Optocoupler isolated. Do NOT use a 5V relay module. |
| Wiring | 24 AWG Solid Core Jumper Wires | Pre-crimed Dupont connectors for Pi header. |
Pin Mapping & Wiring Steps
The Raspberry Pi 4 uses the standard 40-pin header. I2C bus 1 is the default hardware bus exposed to user-space applications. GPIO 17 is our chosen output pin for the relay.
| Component Pin | Pi 4 GPIO / Function | Physical Pin # | Wire Color (Suggested) |
|---|---|---|---|
| BME280 VCC | 3.3V Power | 1 | Red |
| BME280 GND | Ground | 6 | Black |
| BME280 SDA | GPIO 2 (SDA1) | 3 | Blue |
| BME280 SCL | GPIO 3 (SCL1) | 5 | Yellow |
| Relay VCC | 3.3V Power | 17 | Red |
| Relay GND | Ground | 9 | Black |
| Relay IN | GPIO 17 | 11 | Green |
- Seat the Pi: Mount the Raspberry Pi 4 on a non-conductive surface or in its official case. Ensure the 40-pin header is accessible.
- Wire the I2C Bus: Connect the BME280 VCC to Pin 1 (3.3V), GND to Pin 6, SDA to Pin 3, and SCL to Pin 5. The Pi has onboard 1.8kΩ pull-up resistors for I2C bus 1, so external pull-ups are not required for short wire runs (<30cm).
- Wire the Relay: Connect the 3.3V Relay VCC to Pin 17 (3.3V), GND to Pin 9, and the IN signal pin to Pin 11 (GPIO 17).
- Verify Connections: Use a multimeter in continuity mode to verify that no adjacent pins on the Pi header are bridged by stray wire strands before applying power.
The Node.js Control Script
This script targets the Raspberry Pi 4 Model B (4GB) running Raspberry Pi OS Bookworm. It uses the onoff library for GPIO manipulation and i2c-bus alongside bme280-sensor for environmental readings.
First, initialize your project and install dependencies:
mkdir pi-env-relay && cd pi-env-relay
npm init -y
npm install onoff i2c-bus bme280-sensor
Create index.js and paste the following complete, compilable code:
const { Gpio } = require('onoff');
const i2c = require('i2c-bus');
const BME280 = require('bme280-sensor');
// --- PIN & BUS DEFINITIONS ---
const RELAY_PIN = 17; // GPIO 17 (Physical Pin 11)
const I2C_BUS_ID = 1; // I2C Bus 1 (Physical Pins 3 & 5)
const BME280_ADDR = 0x76; // Default I2C address for most BME280 breakouts
const TEMP_THRESHOLD_C = 26.0; // Relay triggers above this temperature
const POLL_INTERVAL_MS = 5000; // Read every 5 seconds
// Initialize GPIO
const relay = new Gpio(RELAY_PIN, 'out');
let relayState = false;
async function main() {
console.log(`[INIT] Targeting I2C Bus ${I2C_BUS_ID}, Relay on GPIO ${RELAY_PIN}`);
let i2cBus;
let sensor;
try {
// Open I2C bus synchronously (throws if /dev/i2c-1 is missing)
i2cBus = i2c.openSync(I2C_BUS_ID);
// Initialize BME280
sensor = new BME280(i2cBus, BME280_ADDR);
await sensor.init();
console.log('[INIT] BME280 sensor initialized successfully.');
} catch (err) {
console.error('[FATAL] Hardware initialization failed:', err.message);
cleanupAndExit(1);
}
// Main polling loop
const intervalId = setInterval(async () => {
try {
const reading = await sensor.readSensorData();
const tempC = reading.temperature_C.toFixed(2);
const humidity = reading.humidity.toFixed(1);
console.log(`[DATA] Temp: ${tempC}°C | Humidity: ${humidity}%`);
// Hysteresis logic to prevent relay chatter at the threshold boundary
if (parseFloat(tempC) > TEMP_THRESHOLD_C && !relayState) {
relay.writeSync(1); // Energize relay (Active High for this module)
relayState = true;
console.log(`[ACT] Relay ON (Temp exceeded ${TEMP_THRESHOLD_C}°C)`);
} else if (parseFloat(tempC) < (TEMP_THRESHOLD_C - 1.0) && relayState) {
relay.writeSync(0); // De-energize relay
relayState = false;
console.log(`[ACT] Relay OFF (Temp dropped below hysteresis band)`);
}
} catch (readErr) {
console.error('[ERROR] Sensor read failed:', readErr.message);
}
}, POLL_INTERVAL_MS);
// Store interval ID for cleanup
global.pollingInterval = intervalId;
}
function cleanupAndExit(code = 0) {
console.log('[EXIT] Cleaning up GPIO and I2C resources...');
if (global.pollingInterval) clearInterval(global.pollingInterval);
try {
relay.writeSync(0);
relay.unexport();
} catch (e) {
// Ignore cleanup errors on exit
}
process.exit(code);
}
// Handle graceful shutdown on Ctrl+C or system kill
process.on('SIGINT', () => cleanupAndExit(0));
process.on('SIGTERM', () => cleanupAndExit(0));
main();
onoff library manipulates the Linux sysfs GPIO interface. It requires root privileges or membership in the gpio user group. If you get an EPERM error on the GPIO export, run your script with sudo node index.js or add your user to the gpio group via sudo usermod -aG gpio $USER.
Debugging: "ENOENT: no such file or directory, open '/dev/i2c-1'"
When bridging a Raspberry Pi and Node.js with I2C hardware, the most frequent roadblock occurs the moment the script attempts to open the I2C bus. You will see this exact error string in your terminal:
Error: ENOENT: no such file or directory, open '/dev/i2c-1'
This is not a Node.js bug; it is a Linux kernel device-tree issue. The OS has not loaded the I2C kernel module, meaning the /dev/i2c-1 character device simply does not exist in the filesystem.
Ranked Causes
- I2C Interface Disabled (90% of cases): Raspberry Pi OS ships with I2C disabled by default to save a marginal amount of boot time and memory.
- Wrong Bus ID Specified (8% of cases): The code requests bus
1, but the physical wiring is on the deprecated bus0(Pins 27/28), or a Pi Compute Module is using a different bus mapping. - Missing dtoverlay in config.txt (2% of cases): A custom OS image or headless setup script failed to inject
dtparam=i2c_arm=oninto/boot/firmware/config.txt.
The First Three Things to Check When It Fails
Do not rewrite your Node.js code. The hardware interface is missing. Run these three diagnostic steps in your Pi's terminal:
- Verify the device node exists:
ls -l /dev/i2c*
Expected:crw-rw---- 1 root i2c 89, 1 ... /dev/i2c-1. If it returns "No such file or directory", proceed to step 2. - Enable I2C via raspi-config:
sudo raspi-config
Navigate to Interface Options -> I2C -> Yes. Reboot the Pi (sudo reboot). - Scan the bus for the sensor:
sudo apt install i2c-tools && i2cdetect -y 1
Expected: A grid output showing76or77at the intersection of row 70. If the grid is empty, your SDA/SCL wiring is reversed or the BME280 lacks power.
Extending and Simplifying the Build
Once the baseline script is polling reliably, you have two distinct paths depending on your project goals.
How to Extend (Add MQTT & Cloud Logging):
To integrate this into a broader smart-home or industrial IoT fleet, install the mqtt npm package. Inside the setInterval callback, publish the reading JSON object to a local Mosquitto broker (mqtt://192.168.1.50:1883). Because Node.js is non-blocking, the network publish will not delay your next sensor poll or GPIO state change. For persistent storage, pipe the MQTT payload into a local InfluxDB instance and visualize it with Grafana.
How to Simplify (Drop the Relay):
If you only need data logging, remove the onoff dependency and the relay wiring entirely. Strip the hysteresis logic from the code block. This reduces the physical footprint to just the Pi and the I2C sensor, allowing you to power the entire setup via a standard 5V/3A USB-C wall adapter without worrying about relay coil back-EMF or switching noise corrupting the I2C bus.
Frequently Asked Questions
Is Node.js fast enough for Raspberry Pi hardware PWM and GPIO toggling?
For standard relay switching, reading I2C/SPI sensors, and toggling LEDs, Node.js is more than fast enough. The event loop handles millisecond-level delays effortlessly. However, if you are attempting software-based PWM (Pulse Width Modulation) for dimming LEDs or driving servos, Node.js will fail. The garbage collector and single-threaded event loop introduce microsecond jitter that will cause servos to twitch and LEDs to flicker. For hardware PWM or microsecond-precision toggling on a Raspberry Pi and Node.js setup, offload the timing to a dedicated hardware PWM pin via the pigpio C-library wrapper, or use an external PCA9685 I2C PWM driver board.
How do I auto-start my Raspberry Pi and Node.js script on boot using systemd?
Do not use rc.local or .bashrc for production embedded scripts; they lack restart-on-fail capabilities. Create a systemd service file at /etc/systemd/system/env-relay.service:
[Unit]
Description=BME280 Environmental Relay Controller
After=network.target i2c.service
[Service]
ExecStart=/usr/bin/node /home/pi/pi-env-relay/index.js
Restart=always
User=pi
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
Enable it with sudo systemctl enable env-relay.service and start it with sudo systemctl start env-relay.service. This ensures your script automatically recovers if the I2C bus temporarily locks up or the script throws an unhandled exception.
Can I use Raspberry Pi and Node.js to read 5V analog sensors directly?
No. The Raspberry Pi SoC does not have a built-in Analog-to-Digital Converter (ADC), and its GPIO pins are strictly 3.3V digital. Connecting a 5V analog output directly to a Pi pin will permanently damage the silicon. To read analog sensors (like a 5V soil moisture probe or an MQ-2 gas sensor) with Node.js, you must use an external I2C or SPI ADC module. The ADS1115 (16-bit, 4-channel) is the industry standard for this. Wire the ADS1115 to the Pi's I2C bus, feed the analog sensor's output into the ADS1115 input channel, and use the ads1115 npm package to read the converted digital values safely.






