Why Node.js on Raspberry Pi 5 Requires a New GPIO Approach

If you are migrating older projects to the Raspberry Pi 5, your first Node.js GPIO script will likely crash. The direct answer to why is the OS shift: Raspberry Pi OS Bookworm deprecated the legacy sysfs GPIO interface in favor of the libgpiod character device interface. Popular legacy npm packages like onoff rely on writing to /sys/class/gpio/export, which no longer exists on a stock Pi 5 Bookworm installation.

To run Node.js on Raspberry Pi 5 reliably, you must use libraries that interface with the modern character device (/dev/gpiochipX) API. This shift isn't just administrative; it drastically improves toggle speeds and thread safety by moving GPIO operations out of the virtual filesystem and into direct kernel memory mappings.

Node.js GPIO Library Compatibility Matrix (2026)

Before writing a single line of code, choose the right library for your OS and hardware revision. Here is how the major Node.js GPIO packages perform across the Pi 4 and Pi 5 ecosystems.

npm Package Underlying Interface Pi 4 (Bullseye OS) Pi 5 (Bookworm OS) Max Toggle Rate Event Loop Blocking?
onoff sysfs (filesystem) Supported Fails (ENOENT) ~3 kHz No (async epoll)
node-gpiod libgpiod (chardev) Supported Supported ~15 kHz No (async native)
pigpio /dev/mem (direct) Supported Fails (needs patch) ~1 MHz Yes (C-addon sync)
rpi-gpio2 sysfs / gpiod hybrid Supported Partial (gpiod mode) ~5 kHz No

For this build, we are targeting the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS Bookworm (64-bit), and we will use node-gpiod for output control and i2c-bus for sensor communication.

Hardware Bill of Materials & Pin Mapping

This project reads ambient temperature, humidity, and barometric pressure, then triggers a relay when the temperature exceeds a set threshold. Because the Pi 5 GPIO pins are strictly 3.3V tolerant, sending 5V back into a pin will instantly destroy the RP1 southbridge chip.

Safety Callout: Relay Voltage Mismatch
Standard 5V relay modules often require a 5V logic HIGH signal to trigger the optocoupler. If you connect a standard 5V relay signal pin directly to a Pi 5 3.3V GPIO, it will not trigger. Worse, if you wire the relay VCC to 5V and the signal back to the Pi, back-EMF can fry the GPIO. You must use a 3.3V-compatible relay module (e.g., JC44A-3.3V or modules explicitly stating '3.3V logic trigger') or use a logic-level MOSFET like a 2N7000 to step the 3.3V signal up to 5V.

Parts List

  • Compute: Raspberry Pi 5 (8GB) with active cooler and 27W USB-C PD power supply.
  • Sensor: Adafruit BME280 I2C/SPI breakout (Product ID 2652) or generic GY-BME280 module.
  • Actuator: 3.3V Logic-Compatible 1-Channel Optocoupler Relay Module (5V coil, 3.3V trigger).
  • Wiring: 22 AWG solid core jumper wires, half-size breadboard.
  • Software: Node.js v20.x LTS (installed via NodeSource).

Pin Mapping Table

Component Component Pin Pi 5 Physical Pin Pi 5 GPIO / Bus Notes
BME280 VIN 1 (3.3V) 3.3V Power Do not use 5V pin
BME280 GND 6 Ground Common ground
BME280 SCK (SCL) 5 GPIO 3 (I2C1 SCL) Pull-ups enabled by Pi
BME280 SDI (SDA) 3 GPIO 2 (I2C1 SDA) Address 0x76 or 0x77
Relay Module VCC 2 (5V) 5V Power Powers the relay coil
Relay Module GND 9 Ground Common ground
Relay Module IN (Trigger) 11 GPIO 17 3.3V logic output

Wiring the BME280 and Relay Module

Follow these steps to wire the physical layer. Always de-energize the Pi (unplug the USB-C PD cable) before making I2C or GPIO connections to prevent shorting the 3.3V rail.

  1. Establish Common Ground: Connect the Pi's Physical Pin 6 (GND) to the breadboard's negative rail. Connect the Relay Module's GND and the BME280's GND to this same negative rail.
  2. Wire I2C Data Lines: Connect Pi Pin 3 (SDA) to BME280 SDI. Connect Pi Pin 5 (SCL) to BME280 SCK. The Raspberry Pi 5 has internal 1.8kΩ pull-up resistors on the primary I2C bus, so external pull-ups are not required for short wire runs (<30cm).
  3. Power the Sensor: Connect Pi Pin 1 (3.3V) to BME280 VIN. Verify your specific breakout board has an onboard voltage regulator; if it is a raw 3.3V chip, feeding it 5V will destroy it.
  4. Wire the Relay Trigger: Connect Pi Pin 11 (GPIO 17) to the Relay Module's IN pin.
  5. Power the Relay Coil: Connect Pi Pin 2 (5V) to the Relay Module's VCC. The optocoupler inside the module will isolate the 5V coil circuit from the 3.3V Pi GPIO trigger circuit.
  6. Verify Connections: Use a multimeter in continuity mode to verify that no adjacent pins on the Pi header are bridged. Plug in the Pi and boot to the desktop or SSH terminal.

The Node.js Control Script

Before running the code, install the required dependencies and enable the I2C interface. Run sudo raspi-config, navigate to Interface Options -> I2C, and enable it. Then install the Node packages:

mkdir pi5-env-monitor && cd pi5-env-monitor
npm init -y
npm install node-gpiod i2c-bus bme280-sensor

Create a file named monitor.js. This script initializes the I2C bus, reads the BME280 calibration data, polls the sensor every 5 seconds, and toggles GPIO 17 if the temperature exceeds 26.0°C.

const gpiod = require('node-gpiod');
const BME280 = require('bme280-sensor');

// Configuration constants
const I2C_BUS = 1;
const BME_ADDRESS = 0x76; // Use 0x77 if your breakout has the alternate address
const RELAY_GPIO = 17;
const TEMP_THRESHOLD = 26.0; // Celsius
const POLL_INTERVAL_MS = 5000;

// Pi 5 typically uses 'gpiochip4' for the main header. 
// Verify by running 'gpioinfo' in your terminal.
const GPIO_CHIP = 'gpiochip4'; 

let relayLine = null;

async function setupGPIO() {
    try {
        const chip = new gpiod.Chip(GPIO_CHIP);
        relayLine = chip.getLine(RELAY_GPIO);
        relayLine.request({
            consumer: 'pi5-env-monitor',
            direction: 'output',
            defaultValue: 0
        });
        console.log(`[GPIO] Relay initialized on ${GPIO_CHIP} line ${RELAY_GPIO}`);
    } catch (err) {
        console.error(`[GPIO FATAL] Failed to initialize chip ${GPIO_CHIP}:`, err.message);
        console.error('Hint: Run "gpioinfo" to find the correct chip name for your Pi revision.');
        process.exit(1);
    }
}

async function runMonitor() {
    const sensor = new BME280({ i2cBusNo: I2C_BUS, i2cAddress: BME_ADDRESS });
    
    try {
        await sensor.init();
        console.log('[I2C] BME280 sensor initialized successfully.');
    } catch (err) {
        console.error('[I2C FATAL] Could not find BME280 on bus ' + I2C_BUS + '.');
        console.error('Run "i2cdetect -y 1" to verify wiring and address.');
        process.exit(1);
    }

    setInterval(async () => {
        try {
            const data = await sensor.readSensorData();
            const tempC = data.temperature.toFixed(2);
            const humidity = data.humidity.toFixed(1);
            const pressure = data.pressure.toFixed(1);
            
            console.log(`[DATA] Temp: ${tempC}C | Hum: ${humidity}% | Press: ${pressure} hPa`);
            
            if (parseFloat(tempC) > TEMP_THRESHOLD) {
                relayLine.setValue(1);
                console.log('[ACTION] Threshold exceeded. Relay ENGAGED.');
            } else {
                relayLine.setValue(0);
                console.log('[ACTION] Temp nominal. Relay DISENGAGED.');
            }
        } catch (readErr) {
            console.error('[READ ERROR] I2C communication dropped:', readErr.message);
        }
    }, POLL_INTERVAL_MS);
}

// Graceful shutdown to release GPIO line
process.on('SIGINT', () => {
    console.log('\n[SHUTDOWN] Releasing GPIO resources...');
    if (relayLine) {
        relayLine.setValue(0);
        relayLine.release();
    }
    process.exit(0);
});

(async () => {
    await setupGPIO();
    await runMonitor();
})();

Debugging: The 'ENOENT sysfs' Error and I2C Failures

When building embedded Node.js applications, hardware abstraction layers often mask the real problem. Here is the decision path for the two most common failures on the Pi 5.

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

If you see this exact string in your console, your code is attempting to use the legacy sysfs interface. The kernel no longer exposes GPIO pins as virtual files in the /sys/ directory.

Ranked Causes & Fixes:

  1. Wrong Library (Most Likely): You are using onoff or an outdated fork. Fix: Uninstall onoff and migrate to node-gpiod using the character device API shown in the code above.
  2. Incorrect Chip Name: If using node-gpiod but pointing to gpiochip0 on a Pi 5, it may fail or map to the wrong pins. Fix: Open your terminal and type gpioinfo | head -n 5. Look for the chip name that lists GPIO 2 through 27. On Pi 5, this is almost always gpiochip4.
  3. Permissions Issue: Your user is not in the gpio group, preventing access to /dev/gpiochip4. Fix: Run sudo usermod -aG gpio $USER, then log out and log back in.

I2C Sensor Not Found (BME280 returns undefined)

If the script crashes at sensor.init() with an I2C timeout or 'Remote I/O error', the Pi cannot see the sensor on the bus.

The First Three Things to Check:

  1. Run i2cdetect -y 1: You should see 76 or 77 in the grid. If the grid is empty, your SDA/SCL wires are swapped or disconnected.
  2. Verify I2C is Enabled: Run dmesg | grep i2c. If the kernel hasn't loaded the I2C driver, go back into raspi-config and enable it. A reboot is required after enabling.
  3. Check the SDO Pin: On some raw BME280 breakouts, the I2C address is determined by the SDO pin. If SDO is tied to GND, the address is 0x76. If tied to VCC, it is 0x77. Update the BME_ADDRESS constant in your code to match your physical wiring.

Extending the Build: MQTT and Home Assistant

The standalone script above is excellent for bench testing, but production embedded deployments rarely run in isolation. Here is how to extend or simplify this architecture based on your end goal.

When to Extend: Adding MQTT Telemetry

If you need to log data to a remote dashboard or feed it into Home Assistant, add the mqtt npm package. Instead of just logging to the console inside the setInterval loop, publish a JSON payload:

const mqtt = require('mqtt');
const client = mqtt.connect('mqtt://192.168.1.50:1883');

// Inside your interval loop:
const payload = JSON.stringify({ temp: tempC, hum: humidity, press: pressure });
client.publish('home/env/pi5', payload);

This allows Home Assistant to ingest the data via the MQTT integration without requiring Home Assistant to poll the Pi directly, keeping the Pi's CPU load near zero.

When to Simplify: Switching to Node-RED

If you find yourself writing complex state machines (e.g., 'only trigger the relay if temp is high AND humidity is low AND it's after 8 AM'), pure Node.js code becomes difficult to maintain. In this case, simplify the build by installing Node-RED (sudo apt install nodered). Node-RED runs on Node.js under the hood but provides a visual flow editor. You can use the node-red-node-pi-gpiod palette to interact with the Pi 5's character device GPIOs without writing raw JavaScript, while retaining the ability to drop in custom function nodes for specific math operations.

Bench Note: The Raspberry Pi 5's RP1 chip handles I/O independently of the main BCM2712 CPU. This means your Node.js event loop will not stutter during high-frequency I2C polling, making it vastly superior to the Pi 4 for JavaScript-based edge computing tasks.

By respecting the shift to libgpiod and properly isolating your 5V relay coils, Node.js on Raspberry Pi 5 becomes a highly reliable platform for environmental monitoring and home automation edge nodes.