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.
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.
| 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.
| 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 |
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:
- Is the I2C interface actually enabled? Run
ls /dev/i2c*. If you don't see/dev/i2c-1, you forgot to enable it inraspi-configor your/boot/firmware/config.txtis missingdtparam=i2c_arm=on. - Is the sensor responding to its address? Run
sudo i2cdetect -y 1. You should see18in the grid. If you see--, your wiring is wrong or the sensor is dead. If you seeUU, another kernel driver has claimed the device. - Are you running as the correct user? The default
pioradminuser must be in thei2cgroup. Fix this withsudo usermod -aG i2c $USERand 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:
- User Group Missing (90% of cases): The user executing the script isn't in the
i2cgroup. Rungroupsto verify. Apply theusermodfix mentioned above. - 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
sudoor by addingSHELL=/bin/bashat the top of your crontab. - 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.






