If you are trying to run Node JS in Raspberry Pi 5 hardware, you have likely hit a wall. Almost every tutorial written before late 2023 relies on the onoff or rpio npm packages. Those packages use the legacy sysfs GPIO interface, which the Raspberry Pi Foundation officially deprecated and removed in the Pi 5's RP1 southbridge architecture and the Debian Bookworm OS release.

This guide gives you the modern, working solution. We will build an environmental monitor that reads an I2C temperature sensor and triggers a GPIO relay, using the native pinctrl utility for GPIO and the i2c-bus package for sensor communication. No C++ compilation headaches, no deprecated kernel modules.

The Pi 5 / Bookworm Reality Check

Before wiring anything, you need to know exactly what environment this code targets. Using older libraries on this stack will result in immediate ENOENT crashes.

Target Build Specifications:
  • Board: Raspberry Pi 5 (4GB or 8GB variant)
  • OS: Raspberry Pi OS Bookworm (64-bit, Lite or Desktop)
  • Runtime: Node.js 20.x LTS (Arm64)
  • GPIO Interface: pinctrl (via child_process) — bypassing native node-gyp bindings
  • I2C Interface: /dev/i2c-1 via the i2c-bus npm package

Hardware Spec Sheet & Pin Mapping

We are using an MCP9808 I2C temperature sensor because it requires minimal math compared to the BME280, keeping our Node.js script lean and focused on the hardware interface. The relay is triggered via a standard NPN transistor to protect the Pi 5's 3.3V GPIO logic from the 5V relay coil.

Component Exact Model / Spec Est. Price (2026)
Microcontroller Raspberry Pi 5 (8GB) $80.00
Sensor MCP9808 I2C Breakout (Adafruit or generic) $9.50
Actuator 5V Opto-isolated Relay Module (Active Low/High) $4.00
Transistor 2N2222 NPN (for logic level shifting) $0.15
Resistor 1kΩ (Base resistor for 2N2222) $0.05

Pin Mapping Table

The Pi 5 maintains the standard 40-pin header layout, but remember that the physical pin numbers do not always match the Broadcom (BCM) GPIO numbers used in software.

Function BCM GPIO Physical Pin Connected To
3.3V Power N/A 1 MCP9808 VCC
5V Power N/A 2 Relay Module VCC
I2C SDA 2 3 MCP9808 SDA
I2C SCL 3 5 MCP9808 SCL
Relay Control 21 40 1kΩ Resistor → 2N2222 Base
Ground N/A 6, 9, 14, 20, 25, 30, 34, 39 Sensor GND, Relay GND, 2N2222 Emitter

Step-by-Step Wiring & Environment Setup

Follow these steps to prepare the OS and wire the bench. Always double-check your I2C pull-up resistors; the Pi 5 has onboard 1.8kΩ pull-ups for the primary I2C bus, so most modern breakouts work without external resistors.

  1. Wire the I2C Sensor: Connect MCP9808 VCC to Pin 1 (3.3V), GND to Pin 6, SDA to Pin 3, and SCL to Pin 5.
  2. Wire the Relay Driver: Connect Pin 40 (BCM 21) to a 1kΩ resistor. Connect the other end of the resistor to the Base of the 2N2222 transistor. Connect the Emitter to Ground (Pin 39). Connect the Collector to the Relay Module's IN pin.
  3. Enable I2C in OS: Open terminal and run sudo raspi-config. Navigate to Interface Options → I2C → Enable. Reboot the Pi. (See the official raspi-config documentation for detailed navigation).
  4. Install Node.js 20 LTS: Run the NodeSource setup script for the Node.js 20 LTS release:
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt-get install -y nodejs
  5. Initialize Project: Create a directory, initialize npm, and install the I2C library.
    mkdir pi5-env-monitor && cd pi5-env-monitor
    npm init -y
    npm install i2c-bus

The Complete Node.js Control Script

Create a file named monitor.js. This script reads the raw I2C bytes from the MCP9808, calculates the Celsius temperature, and toggles the relay if the temperature exceeds a threshold. We use child_process.execSync to call the Pi 5's native pinctrl binary. This guarantees compatibility without requiring Python or C++ build tools on your Pi.

const i2c = require('i2c-bus');
const { execSync } = require('child_process');

// --- HARDWARE DEFINITIONS ---
const I2C_BUS_NUM = 1;
const SENSOR_ADDR = 0x18; // MCP9808 default I2C address
const RELAY_PIN_BCM = 21; // Physical Pin 40
const TEMP_REGISTER = 0x05;
const THRESHOLD_C = 26.0; // Trigger relay above 26°C

/**
 * Toggles the GPIO pin using the Pi 5 native pinctrl utility.
 * Bypasses deprecated sysfs interfaces found in older npm packages.
 */
function setRelayState(state) {
  // dh = drive high (turn on transistor/relay), dl = drive low
  const flag = state ? 'dh' : 'dl'; 
  try {
    execSync(`pinctrl set ${RELAY_PIN_BCM} op ${flag}`, { stdio: 'ignore' });
  } catch (err) {
    console.error(`[GPIO ERROR] Failed to set pin ${RELAY_PIN_BCM}:`, err.message);
  }
}

/**
 * Reads and parses the 2-byte temperature register from the MCP9808.
 */
function readTemperatureC() {
  const bus = i2c.openSync(I2C_BUS_NUM);
  try {
    const buffer = Buffer.alloc(2);
    bus.readI2cBlockSync(SENSOR_ADDR, TEMP_REGISTER, 2, buffer);
    
    let upperByte = buffer[0];
    let lowerByte = buffer[1];
    
    // Clear flag bits (bits 13, 14, 15)
    upperByte &= 0x1F; 
    let rawTemp = (upperByte << 8) | lowerByte;
    
    // Handle negative temperatures
    if (rawTemp & 0x1000) {
      rawTemp = rawTemp - 4096;
    }
    
    return rawTemp / 16.0;
  } finally {
    bus.closeSync();
  }
}

// --- MAIN EXECUTION LOOP ---
console.log(`Starting Pi 5 Environmental Monitor on BCM GPIO ${RELAY_PIN_BCM}...`);
setRelayState(false); // Ensure relay starts in OFF state

setInterval(() => {
  try {
    const tempC = readTemperatureC();
    const relayOn = tempC > THRESHOLD_C;
    
    console.log(`[${new Date().toISOString()}] Temp: ${tempC.toFixed(2)}°C | Relay: ${relayOn ? 'ON' : 'OFF'}`);
    setRelayState(relayOn);
    
  } catch (err) {
    console.error('[I2C ERROR] Sensor read failed:', err.message);
    // Fail-safe: turn off relay if we lose sensor communication
    setRelayState(false); 
  }
}, 2000); // Poll every 2 seconds

Run the script with node monitor.js. You should see console output every two seconds. If you pinch the sensor, the temperature will rise and the relay will click on.

Debugging: 3 Exact Errors and How to Fix Them

When embedding Node JS in Raspberry Pi environments, hardware permissions and OS updates are the primary failure points. If your script crashes on startup, check these three exact error strings.

The First Three Things to Check When It Fails:
  1. Did you enable the I2C interface in raspi-config and reboot?
  2. Are you running the script as the default pi (or your custom user) who is in the i2c and gpio groups? (Avoid using sudo node as it breaks npm paths).
  3. Are you using a Pi 4 or Pi 5? The pinctrl command is native to Pi 5 / Bookworm. Pi 4 requires raspi-gpio or vcgencmd.

Error 1: The Sysfs Deprecation Crash

Exact Error String: Error: ENOENT: no such file or directory, open '/sys/class/gpio/export'

Ranked Causes:

  1. Using legacy libraries: You installed onoff, rpio, or pi-gpio. These write to /sys/class/gpio, which the Bookworm update removed in favor of the gpiod character device API.
  2. Fix: Uninstall the legacy package. Switch to the pinctrl CLI wrapper method shown in the code above, or use the lgpio Node.js bindings if you need high-frequency PWM.

Error 2: I2C Permission Denied

Exact Error String: Error: EACCES: permission denied, open '/dev/i2c-1'

Ranked Causes:

  1. User not in I2C group: Your current Linux user lacks hardware access rights.
  2. Fix: Run sudo usermod -aG i2c $USER, then log out and log back in (or reboot) for the group policy to apply.
  3. I2C disabled: The interface is turned off in the OS. Run sudo raspi-config and enable it under Interface Options.

Error 3: pinctrl Binary Missing

Exact Error String: Error: ENOENT: no such file or directory, uv_spawn pinctrl

Ranked Causes:

  1. Wrong Hardware/OS: You are running this exact script on a Raspberry Pi 4 or an older OS (Bullseye/Buster). The pinctrl binary does not exist on those systems.
  2. Fix: If on Pi 4, change the execSync command in the script to use raspi-gpio set ${RELAY_PIN_BCM} op ${flag}. If on Pi 5, ensure your OS is fully updated via sudo apt update && sudo apt full-upgrade.

Extending and Simplifying the Build

Depending on your project phase, you may need to strip this down or scale it up.

To Simplify (Bench Testing): Remove the relay and transistor entirely. Replace the setRelayState() function with a simple console.log() alert. This isolates I2C sensor debugging from GPIO wiring faults. If the console logs temperature correctly, your I2C bus is healthy, and you can safely add the transistor circuit later.

To Extend (Production IoT): Wrap the readTemperatureC() function in an Express.js API route to serve a JSON dashboard, or integrate the mqtt npm package to publish the temperature to a Mosquitto broker every 5 seconds. For production daemonization, do not use nohup; write a systemd service file to ensure the script restarts automatically on power loss and logs to journalctl.

Frequently Asked Questions

Is Node.js fast enough for Raspberry Pi real-time GPIO control?

Node.js is excellent for polling sensors (like I2C/SPI) at intervals of 10ms to 1000ms and handling network-bound IoT tasks. However, because Node.js runs on a single-threaded event loop with garbage collection pauses, it is not suitable for microsecond-precise timing, high-frequency PWM generation, or bit-banging protocols like WS2812B addressable LEDs. For sub-millisecond hardware timing, use a dedicated microcontroller (like an RP2040) and communicate with the Pi via UART or USB.

How do I auto-start my Node JS in Raspberry Pi script on boot?

The most robust method on Pi OS Bookworm is using systemd. Create a file at /etc/systemd/system/env-monitor.service with your ExecStart=/usr/bin/node /home/pi/pi5-env-monitor/monitor.js path. Run sudo systemctl enable env-monitor.service and sudo systemctl start env-monitor.service. This ensures the script runs in the background, starts on boot, and automatically restarts if the code throws an unhandled exception.

Why did my old Node.js GPIO script break on Raspberry Pi OS Bookworm?

Older scripts rely on the sysfs interface (/sys/class/gpio), which allowed user-space applications to export and toggle GPIO pins by writing to text files. The Raspberry Pi Foundation deprecated this in favor of the libgpiod character device API to improve security and performance, especially with the new RP1 chip on the Pi 5. Any npm package that hasn't been updated to use gpiod or the pinctrl CLI will throw ENOENT errors on modern Pi OS builds.