To build a reliable Node.js Raspberry Pi GPIO server that reads environmental data and toggles physical loads, you need a Raspberry Pi 4 Model B, the i2c-bus and onoff npm packages, and I2C bus 1 enabled in your firmware config. This setup gives you a non-blocking, event-driven API server capable of reading a BME280 sensor and switching a relay without locking up the main thread.

While Python dominates quick Pi scripts, Node.js is the superior choice when your project needs to serve a web dashboard, handle concurrent WebSocket connections, or integrate with MQTT brokers. Below is the exact bench-tested blueprint to wire, code, and debug this system, including the critical library differences if you attempt this on the newer Raspberry Pi 5.

Hardware Bill of Materials & Pi 4 vs Pi 5 GPIO Reality

Before writing code, we must address the silicon. The Raspberry Pi 5 introduced the RP1 southbridge chip, which fundamentally changed how GPIO and I2C are mapped in the OS. The classic onoff library relies on the legacy sysfs GPIO interface, which is deprecated and largely non-functional on the Pi 5's default 6.6+ kernel. Therefore, the code in this guide specifically targets the Raspberry Pi 4 Model B (4GB or 8GB variant), which remains the most stable platform for legacy Node.js GPIO libraries in 2026.

Hardware & Library Compatibility Matrix
Feature Raspberry Pi 4 Model B Raspberry Pi 5 (8GB) Node.js Library Impact
GPIO Controller BCM2711 (Integrated) RP1 (Southbridge) Pi 5 breaks onoff and rpio; requires lgpio bindings.
Default Node GPIO Lib onoff (sysfs) lgpio (character device) Use onoff for Pi 4; use lgpio npm package for Pi 5.
Max I2C Bus Speed 400 kHz (Fast Mode) 1 MHz (Fast Mode Plus) BME280 maxes out at 400 kHz; no code change needed between boards.
3.3V Rail Max Current ~500mA (Shared) ~300mA (Strictly limited) Pi 5 requires external 3.3V LDO if driving multiple I2C sensors.
Typical Board Cost (2026) $55 - $75 USD $80 - $110 USD Pi 4 is more cost-effective for simple I/O node servers.
Bench Tip: If you must use a Pi 5, replace onoff with the lgpio npm wrapper. The API is slightly different (lgpio.gpioClaimOutput instead of new Gpio), but the I2C i2c-bus library works identically on both boards.

Parts List

  • Board: Raspberry Pi 4 Model B (4GB) with official 27W USB-C power supply.
  • Sensor: BME280 I2C breakout board (Adafruit 2652 or generic with 3.3V regulator).
  • Actuator: 5V Relay Module (Active-Low, Optocoupler isolated, e.g., Songle SRD-05VDC-SL-C).
  • Wiring: 22 AWG solid core jumper wires, female-to-female Dupont connectors.
  • Software: Raspberry Pi OS (64-bit, Bookworm or later), Node.js v20 LTS.

Pin Mapping & Wiring the BME280 and Relay

Wiring I2C and relays to the Pi requires strict attention to voltage levels. The Pi's GPIO pins are strictly 3.3V tolerant. Feeding 5V back into a GPIO pin will permanently destroy the BCM2711 SoC. The BME280 runs natively on 3.3V. The relay module is powered by the Pi's 5V rail, but its logic input (IN) must be driven by a 3.3V GPIO pin.

Physical Pin to BCM GPIO Mapping
Pi Physical Pin BCM GPIO Function Connected Component
Pin 1 3.3V Power VCC BME280 VIN
Pin 3 GPIO 2 (SDA1) I2C Data BME280 SDI/SDA
Pin 5 GPIO 3 (SCL1) I2C Clock BME280 SCK/SCL
Pin 6 GND Ground BME280 GND
Pin 2 5V Power VCC Relay Module VCC
Pin 14 GND Ground Relay Module GND
Pin 11 GPIO 17 Digital Out Relay Module IN (Active-Low)
Safety Warning: Never connect the relay's high-voltage side (COM/NO/NC) to the Pi's low-voltage DC side. Ensure your mains AC wiring on the relay output is housed in an insulated, IP-rated enclosure. Local electrical codes require qualified personnel for permanent mains wiring.

The Node.js Raspberry Pi Server Code

This Express server exposes two endpoints: /api/sensor to fetch live BME280 data, and /api/relay/:state to toggle the load. It includes proper error handling for I2C bus failures and ensures GPIO pins are unexported gracefully when the process terminates.

Prerequisites: Enable I2C via sudo raspi-config (Interface Options > I2C), then run npm install express onoff i2c-bus.


const express = require('express');
const i2c = require('i2c-bus');
const Gpio = require('onoff').Gpio;

const app = express();
const PORT = 3000;

// --- PIN & ADDRESS DEFINITIONS ---
const RELAY_BCM_PIN = 17;
const BME280_I2C_BUS = 1;
const BME280_ADDRESS = 0x77; // Use 0x76 if your breakout board has the alternate jumper

// --- HARDWARE INITIALIZATION ---
// Relay configured as output, active-low logic (0 = ON, 1 = OFF)
const relay = new Gpio(RELAY_BCM_PIN, 'out');
relay.writeSync(1); // Ensure relay is OFF on startup

let i2cBus;
try {
  i2cBus = i2c.openSync(BME280_I2C_BUS);
  console.log(`[INIT] I2C Bus ${BME280_I2C_BUS} opened successfully.`);
} catch (err) {
  console.error(`[FATAL] Failed to open I2C bus: ${err.message}`);
  process.exit(1);
}

// Helper: Read raw BME280 registers (simplified for demonstration)
// In production, use the 'bme280-sensor' npm package for calibrated math.
function readSensorData() {
  try {
    // Check if device is present on the bus
    if (!i2cBus.scanSync().includes(BME280_ADDRESS)) {
      throw new Error(`BME280 not found at address 0x${BME280_ADDRESS.toString(16)}`);
    }
    
    // Read Chip ID register (0xD0) to verify communication
    const chipId = i2cBus.readByteSync(BME280_ADDRESS, 0xD0);
    if (chipId !== 0x60) {
      throw new Error(`Invalid Chip ID: 0x${chipId.toString(16)}. Expected 0x60.`);
    }

    // Placeholder: Real implementation requires reading calibration registers 
    // and applying the Bosch compensation algorithm.
    return {
      status: 'online',
      chip_id: chipId,
      message: 'Use bme280-sensor package for full temp/hum/press compensation.'
    };
  } catch (err) {
    return { status: 'error', message: err.message };
  }
}

// --- API ROUTES ---
app.get('/api/sensor', (req, res) => {
  const data = readSensorData();
  if (data.status === 'error') {
    return res.status(500).json(data);
  }
  res.json(data);
});

app.get('/api/relay/:state', (req, res) => {
  const state = req.params.state.toLowerCase();
  try {
    if (state === 'on') {
      relay.writeSync(0); // Active-low: 0 triggers the optocoupler
      res.json({ relay: 'ON', bcm_pin: RELAY_BCM_PIN });
    } else if (state === 'off') {
      relay.writeSync(1);
      res.json({ relay: 'OFF', bcm_pin: RELAY_BCM_PIN });
    } else {
      res.status(400).json({ error: 'Invalid state. Use /api/relay/on or /api/relay/off' });
    }
  } catch (err) {
    res.status(500).json({ error: `GPIO write failed: ${err.message}` });
  }
});

// --- GRACEFUL SHUTDOWN ---
function cleanup() {
  console.log('\n[SHUTDOWN] Unexporting GPIO pins and closing I2C...');
  relay.unexport();
  if (i2cBus) i2cBus.closeSync();
  process.exit(0);
}

process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);

app.listen(PORT, () => {
  console.log(`[SERVER] Node.js Raspberry Pi server listening on port ${PORT}`);
});

Debugging: Exact Errors & The First Three Things to Check

When working with Node.js on embedded Linux, permissions and hardware states are the primary culprits for crashes. If your server fails to start, look for these exact error strings in your terminal.

1. The GPIO Permission Error

Exact Error String: Error: EPERM: operation not permitted, open '/sys/class/gpio/export'

Ranked Causes:

  1. User not in GPIO group: You are running the script as a standard user (e.g., 'pi') who lacks sysfs write permissions.
  2. Pin already exported: A previous crash left the pin locked. Another process (like a Python script or systemd service) is actively holding GPIO 17.
  3. Raspberry Pi 5 Incompatibility: You are running this exact code on a Pi 5, where the /sys/class/gpio interface is disabled by default in favor of the character device (/dev/gpiochip).

2. The I2C Bus Missing Error

Exact Error String: Error: ENOENT: no such file or directory, open '/dev/i2c-1'

Ranked Causes:

  1. I2C interface disabled: You forgot to enable I2C in raspi-config.
  2. Wrong bus number: You are using a Pi Zero or Compute Module where the primary I2C bus might be mapped to /dev/i2c-0 or /dev/i2c-11.
The First 3 Things to Check When It Fails:
  1. Verify I2C Hardware: Run i2cdetect -y 1 in the terminal. If you don't see 77 (or 76) in the grid, your wiring is wrong or the sensor is dead. Check SDA/SCL continuity with a multimeter.
  2. Check User Groups: Run groups. If i2c and gpio are missing, fix it with sudo usermod -aG i2c,gpio $USER, then log out and log back in.
  3. Clear Zombie Exports: If the relay pin is stuck, run echo 17 | sudo tee /sys/class/gpio/unexport to force-release it before restarting your Node app.

Extending and Simplifying the Build

Once the baseline server is stable, you will inevitably need to adapt it for production or scale it down for simpler tasks.

How to Extend for Production

  • Process Management: Never run this via node server.js in a raw terminal. Install PM2 (npm install -g pm2) and start it with pm2 start server.js --name pi-gpio. PM2 handles auto-restarts on crash and logs stdout/stderr to disk.
  • Full Sensor Math: Replace the placeholder I2C read function with the bme280-sensor npm package. It handles the complex Bosch floating-point calibration math required to convert raw ADC registers into accurate °C and hPa readings.
  • MQTT Integration: Instead of polling via HTTP, add the mqtt npm package to publish sensor readings to a local Mosquitto broker every 5 seconds, allowing Home Assistant to ingest the data without REST overhead.

How to Simplify (When Node.js is Overkill)

If your goal is simply to log temperature to an SD card once a minute, Node.js is the wrong tool. The V8 engine consumes ~30-40MB of RAM at idle. For ultra-low-power or memory-constrained setups (like a Pi Zero 2 W with 512MB RAM), write a 15-line Python script using the smbus2 library and schedule it via cron. Alternatively, abandon the Pi entirely and use an ESP32 running MicroPython, which can deep-sleep between reads and operate on milliwatts of power.

For further reading on Raspberry Pi hardware interfaces, consult the official Raspberry Pi I2C documentation and the onoff GitHub repository for advanced edge-triggered interrupt handling.