Building a reliable sensor node for Raspberry Pi environments requires moving beyond basic, blocking Python scripts to asynchronous, event-driven architectures. When you need an edge device that samples environmental data, handles network drops gracefully, and publishes to an MQTT broker without locking up the CPU, Node.js is the superior runtime.

In this guide, we will build a high-accuracy temperature monitoring node using a Raspberry Pi 5 (4GB) and an Adafruit MCP9808 I2C temperature sensor. We will wire the hardware, write a production-ready Node.js script with raw I2C bit-shifting and MQTT auto-reconnect logic, and cover the exact debugging steps when the I2C bus inevitably throws permission or addressing errors.

Difficulty: Intermediate (2/5) | Time: 45 Minutes | Cost: ~$85 USD

Project Overview and Hardware Spec Sheet

The Raspberry Pi 5 utilizes the custom RP1 southbridge chip for peripheral management. While this drastically improves USB and Ethernet throughput, it means GPIO voltage tolerance is strictly 3.3V. Feeding 5V logic into the I2C pins will permanently destroy the RP1 chip. The MCP9808 is a 3.3V native sensor, making it a perfect, safe match for the Pi 5 without requiring a logic level converter.

Table 1: Required Components and Exact Variants
Component Exact Variant / Model Notes
Microcontroller Raspberry Pi 5 (4GB or 8GB) Requires active cooler; runs hotter than Pi 4.
Sensor Adafruit MCP9808 High Accuracy I2C Temp Sensor (PID: 1782) ±0.25°C accuracy. Default I2C address: 0x18.
Power Supply Official Raspberry Pi 27W USB-C PD Power Supply Pi 5 requires 5V/5A PD for full peripheral support.
Wiring 24 AWG Silicone Jumper Wires (Female-to-Female) Silicone prevents melting near the Pi 5 SoC.

Pin Mapping and Physical Wiring

The Pi 5 exposes its primary I2C bus (I2C1) on the standard 40-pin header. We will use physical pins 1, 3, 5, and 6. Double-check your wiring before applying power; the Pi 5's 5V pin (Physical Pin 2 or 4) sits directly adjacent to the 3.3V pin.

Table 2: Pi 5 to MCP9808 Pin Mapping
Pi 5 Physical Pin GPIO / Function MCP9808 Breakout Pin
Pin 1 3.3V Power VIN (or VDD)
Pin 3 GPIO 2 (SDA1) SDA
Pin 5 GPIO 3 (SCL1) SCL
Pin 6 Ground (GND) GND
Wiring Warning: Do not connect the MCP9808 to the 5V pin. While some breakouts have onboard regulators, the Adafruit PID 1782 expects 3.3V to 5V, but the I2C data lines will output at the VIN voltage. If you power it with 5V, it will push 5V back into the Pi 5's 3.3V GPIO pins, causing a brownout or silicon damage.

Environment Setup and Complete Node.js Code

Before writing code, enable the I2C interface on the Pi 5. Open the terminal and run sudo raspi-config, navigate to Interface Options > I2C, and enable it. Reboot the Pi. For deeper configuration details, refer to the official Raspberry Pi config.txt documentation.

Next, initialize your project and install the required packages. We use i2c-bus for raw hardware access and mqtt for the network transport layer.

mkdir pi-sensor-node && cd pi-sensor-node
npm init -y
npm install i2c-bus mqtt

Create a file named sensor-node.js and paste the following complete, compilable code. This script reads the raw I2C registers of the MCP9808, performs the bitwise math required by the datasheet to calculate the temperature, and publishes it to an MQTT broker with automatic reconnection handling.

const i2c = require('i2c-bus');
const mqtt = require('mqtt');

// --- HARDWARE DEFINITIONS ---
const I2C_BUS_NUM = 1;       // /dev/i2c-1 on Pi 5 GPIO 2/3
const MCP9808_ADDR = 0x18;   // Default I2C address for Adafruit MCP9808
const TEMP_REGISTER = 0x05;  // Ambient Temperature Register

// --- NETWORK DEFINITIONS ---
const MQTT_BROKER = 'mqtt://192.168.1.100:1883';
const MQTT_TOPIC = 'workshop/pi5/temperature';
const POLL_INTERVAL_MS = 5000;

let i2cBus;
let mqttClient;

function initHardware() {
  try {
    i2cBus = i2c.openSync(I2C_BUS_NUM);
    // Verify connection by reading the Manufacturer ID register (0x06)
    const buffer = Buffer.alloc(2);
    i2cBus.readI2cBlockSync(MCP9808_ADDR, 0x06, 2, buffer);
    const manufId = buffer.readUInt16BE(0);
    if (manufId !== 0x0054) {
      throw new Error(`Unexpected Manufacturer ID: 0x${manufId.toString(16)}. Expected 0x0054.`);
    }
    console.log('[INIT] MCP9808 verified on I2C bus 1.');
  } catch (err) {
    console.error('[FATAL] Hardware initialization failed:', err.message);
    process.exit(1);
  }
}

function readTemperature() {
  const buffer = Buffer.alloc(2);
  // Read 2 bytes from the Ambient Temperature Register
  i2cBus.readI2cBlockSync(MCP9808_ADDR, TEMP_REGISTER, 2, buffer);
  
  let upperByte = buffer[0];
  let lowerByte = buffer[1];
  
  // Clear flag bits (Alert, Critical, Window) from the upper byte
  upperByte &= 0x1F;
  
  // Calculate temperature according to MCP9808 datasheet
  let tempC = (upperByte * 256 + lowerByte) / 16.0;
  if (upperByte & 0x10) {
    tempC -= 256.0; // Handle negative temperatures
  }
  
  return tempC.toFixed(2);
}

function initNetwork() {
  mqttClient = mqtt.connect(MQTT_BROKER, {
    clientId: `pi5_node_${Math.random().toString(16).slice(2, 8)}`,
    reconnectPeriod: 5000,
    connectTimeout: 10000
  });

  mqttClient.on('connect', () => {
    console.log('[MQTT] Connected to broker.');
  });

  mqttClient.on('error', (err) => {
    console.error('[MQTT] Connection error:', err.message);
  });

  mqttClient.on('offline', () => {
    console.warn('[MQTT] Client is offline. Buffering or dropping messages.');
  });
}

function mainLoop() {
  setInterval(() => {
    try {
      const temp = readTemperature();
      console.log(`[DATA] Temperature: ${temp}°C`);
      
      if (mqttClient && mqttClient.connected) {
        mqttClient.publish(MQTT_TOPIC, temp, { qos: 1 }, (err) => {
          if (err) console.error('[MQTT] Publish failed:', err.message);
        });
      }
    } catch (err) {
      console.error('[ERROR] I2C Read failed:', err.message);
    }
  }, POLL_INTERVAL_MS);
}

// --- EXECUTION ---
initHardware();
initNetwork();
mainLoop();

Run the script using node sensor-node.js. For more details on the I2C methods used, consult the i2c-bus NPM documentation.

Debugging: First Three Checks and Common I2C Errors

When deploying hardware nodes, failure is the default state until proven otherwise. If your script crashes on startup, execute these first three things to check:

  1. Is the I2C interface actually enabled? Run ls /dev/i2c*. If you don't see /dev/i2c-1, you forgot to enable it in raspi-config or your /boot/firmware/config.txt is missing dtparam=i2c_arm=on.
  2. Is the sensor responding to its address? Run sudo i2cdetect -y 1. You should see 18 in the grid. If you see --, your wiring is wrong or the sensor is dead. If you see UU, another kernel driver has claimed the device.
  3. Are you running as the correct user? The default pi or admin user must be in the i2c group. Fix this with sudo usermod -aG i2c $USER and log out/in.

The "EACCES" Permission Error

The most common fatal error when running this script via cron or a systemd service is:

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

Ranked Causes and Fixes:

  1. User Group Missing (90% of cases): The user executing the script isn't in the i2c group. Run groups to verify. Apply the usermod fix mentioned above.
  2. Cron Environment Stripping (8% of cases): Cron runs with a stripped environment and sometimes ignores secondary group memberships depending on the PAM configuration. Fix this by running the cron job with sudo or by adding SHELL=/bin/bash at the top of your crontab.
  3. AppArmor / SELinux Blocking (2% of cases): Rare on standard Raspberry Pi OS, but if you've installed security modules, they may block Node.js from accessing character devices. Check dmesg | grep DENIED.

Extending or Simplifying Your IoT Node

Not every project requires a full MQTT broker, and some require more local control. Here is how to adapt this build to your specific constraints.

How to Simplify: If you don't have an MQTT broker (like Mosquitto) running on your network, strip out the mqtt package entirely. Replace the mqttClient.publish block with Node's native fs.appendFile to log the temperature and a timestamp to a local CSV file on the Pi's SD card. This reduces network dependencies to zero.

How to Extend: To turn this from a passive monitor into an active controller, add a 5V Relay Module wired to GPIO 17 (Physical Pin 11). Use the onoff npm package to define GPIO 17 as an output. Inside the mainLoop, add a conditional check: if tempC < 18.0, set the GPIO high to trigger the relay and turn on a space heater or heat lamp. Ensure you use a flyback diode across the relay coil to protect the Pi's GPIO from inductive voltage spikes.

Frequently Asked Questions

Is Node-RED better than a custom Node.js script for a Raspberry Pi node?

Node-RED is excellent for rapid prototyping and visualizing data flows, but it carries significant overhead. A custom Node.js script (like the one provided above) uses roughly 15-20MB of RAM, whereas Node-RED and its underlying Node-RED runtime can easily consume 150MB+. If you are running a Pi 5 with 4GB of RAM and multiple other services (like Home Assistant or Frigate NVR), the custom script is vastly more resource-efficient and easier to version-control in Git.

Why does my Raspberry Pi 5 I2C node drop connections under heavy CPU load?

The Pi 5 routes its GPIO through the RP1 southbridge chip via a PCIe link. Under extreme CPU or USB 3.0 bus saturation, the PCIe latency can spike, causing the I2C clock to stretch or miss timing windows, resulting in EREMOTEIO (Remote I/O error) or EIO in your Node.js logs. To mitigate this, ensure your Pi 5 is using the official 27W power supply to prevent peripheral brownouts, and add a software retry wrapper around your readI2cBlockSync calls.

Can I power the Raspberry Pi IoT node directly from a 12V solar battery system?

Do not wire a 12V lead-acid or LiFePO4 battery directly to the Pi's 5V GPIO pins; you will instantly destroy the board. The Pi 5 requires a USB-C Power Delivery (PD) negotiation to safely enable the 5A rail. Use a dedicated USB-C PD trigger board paired with a high-efficiency buck converter (like the RECOM R-78E5.0-1.0) to step the 12V solar battery down to 5V, and wire it into a USB-C breakout plug. Alternatively, use a purpose-built Pi UPS HAT that accepts 12V input and handles the PD handshake natively.

How do I auto-start my Node.js sensor node on boot without using PM2?

While PM2 is popular, it adds unnecessary bloat for a single-purpose edge node. The native Linux systemd is the correct tool. Create a file at /etc/systemd/system/sensor-node.service with the following configuration:

[Unit]
Description=Pi5 MCP9808 IoT Node
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/node /home/pi/pi-sensor-node/sensor-node.js
WorkingDirectory=/home/pi/pi-sensor-node
Restart=always
User=pi
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Enable it with sudo systemctl enable sensor-node.service and start it with sudo systemctl start sensor-node.service. This ensures the script restarts automatically if the I2C bus crashes or the MQTT broker drops.