Reading Sensors on a Raspberry Pi 4 over I2C and SPI with Python

Simple on/off inputs are enough for buttons, but most real sensors send numbers: light level, temperature, pressure, acceleration. They talk to the Raspberry Pi 4 over serial buses, and the two you will meet most often are I2C and SPI. The Pi 4 also has no analogue inputs, so reading a potentiometer or an LDR needs an external analogue-to-digital converter, which is usually an SPI chip.

In this tutorial you will enable both buses, read a BH1750 digital light sensor over I2C, read a potentiometer through an MCP3008 10-bit ADC over SPI, and combine the two into a logger that writes timestamped readings to a CSV file. Both drivers are written directly against the datasheet commands, so you will understand every byte that crosses the wire.

I2C and SPI Compared

FeatureI2CSPI
Wires2 (SDA, SCL) plus GND4 (MOSI, MISO, SCLK, CE) plus GND
Device selection7-bit address in each messageSeparate chip-enable line per device
Pi 4 pins (bus used here)GPIO2 SDA, GPIO3 SCL (pins 3, 5)GPIO10 MOSI, GPIO9 MISO, GPIO11 SCLK, GPIO8 CE0 (pins 19, 21, 23, 24)
Linux device/dev/i2c-1/dev/spidev0.0
Typical speed100 or 400 kHz1 MHz and above
Python librarysmbus2spidev

I2C saves pins and lets many sensors share two wires. SPI is faster and simpler electrically but needs one chip-enable line per device.

Enabling the Buses and Wiring

Run sudo raspi-config, open Interface Options, and enable both I2C and SPI. Equivalent lines dtparam=i2c_arm=on and dtparam=spi=on are added to /boot/firmware/config.txt. Reboot, then install the tools and libraries:

sudo apt install -y i2c-tools python3-smbus2 python3-spidev
ls /dev/i2c-1 /dev/spidev0.*     # both should exist
i2cdetect -y 1                   # scan the I2C bus

If apt cannot find python3-smbus2 on your image, create a virtual environment with python3 -m venv --system-site-packages ~/sensors and run ~/sensors/bin/pip install smbus2 instead; Bookworm blocks system-wide pip installs.

Module pinConnect to Pi 4Physical pin
BH1750 VCC3.3 V1
BH1750 GNDGND6
BH1750 SDAGPIO23
BH1750 SCLGPIO35
BH1750 ADDRGND (address 0x23)-
MCP3008 VDD and VREF (16, 15)3.3 V17
MCP3008 AGND and DGND (14, 9)GND20
MCP3008 CLK (13)GPIO11 SCLK23
MCP3008 DOUT (12)GPIO9 MISO21
MCP3008 DIN (11)GPIO10 MOSI19
MCP3008 CS (10)GPIO8 CE024
Potentiometer wiperMCP3008 CH0 (pin 1)-

With the BH1750 connected, i2cdetect -y 1 should show 23 in the grid. If ADDR is tied high, the address becomes 0x5C. Power the MCP3008 from 3.3 V, not 5 V, so its output on MISO stays safe for the Pi.

The Sensor Logger

sensor_logger.py - BH1750 over I2C and MCP3008 over SPI, logged to CSV
#!/usr/bin/env python3
# sensor_logger.py - read a BH1750 (I2C) and an MCP3008 (SPI) every 2 seconds
import csv
import time
from datetime import datetime

import spidev
from smbus2 import SMBus, i2c_msg

# ---------------- BH1750 light sensor (I2C) ----------------
BH1750_ADDR = 0x23          # ADDR pin low; 0x5C if ADDR is high
POWER_ON = 0x01
CONT_HRES_MODE = 0x10       # continuous, 1 lx resolution, ~120 ms per reading

class BH1750:
    def __init__(self, bus):
        self.bus = bus
        # The BH1750 takes single-byte commands with no register address,
        # so we send raw I2C messages instead of register writes.
        self.bus.i2c_rdwr(i2c_msg.write(BH1750_ADDR, [POWER_ON]))
        self.bus.i2c_rdwr(i2c_msg.write(BH1750_ADDR, [CONT_HRES_MODE]))
        time.sleep(0.18)    # wait for the first conversion

    def lux(self):
        msg = i2c_msg.read(BH1750_ADDR, 2)
        self.bus.i2c_rdwr(msg)
        high, low = list(msg)
        raw = (high << 8) | low
        return raw / 1.2    # datasheet conversion factor

# ---------------- MCP3008 ADC (SPI) ----------------
class MCP3008:
    def __init__(self, bus=0, device=0):
        self.spi = spidev.SpiDev()
        self.spi.open(bus, device)      # /dev/spidev0.0 -> CE0
        self.spi.max_speed_hz = 1_000_000
        self.spi.mode = 0

    def read(self, channel):
        """Return the 10-bit value (0-1023) of a single-ended channel 0-7."""
        if not 0 <= channel <= 7:
            raise ValueError('channel must be 0-7')
        # Byte 1: start bit. Byte 2: single-ended flag + channel in top nibble.
        reply = self.spi.xfer2([0x01, (0x08 | channel) << 4, 0x00])
        # Result: last 2 bits of byte 1 and all 8 bits of byte 2
        return ((reply[1] & 0x03) << 8) | reply[2]

    def close(self):
        self.spi.close()

# ---------------- main loop ----------------
def main():
    adc = MCP3008()
    with SMBus(1) as bus, open('readings.csv', 'a', newline='') as f:
        light = BH1750(bus)
        writer = csv.writer(f)
        if f.tell() == 0:                      # new file: write a header
            writer.writerow(['time', 'lux', 'adc_raw', 'volts'])
        try:
            while True:
                lux = light.lux()
                raw = adc.read(0)
                volts = raw * 3.3 / 1023       # VREF is 3.3 V
                now = datetime.now().isoformat(timespec='seconds')
                writer.writerow([now, f'{lux:.1f}', raw, f'{volts:.3f}'])
                f.flush()                      # survive a power cut
                print(f'{now}  {lux:8.1f} lx  ADC={raw:4d}  {volts:.3f} V')
                time.sleep(2)
        except KeyboardInterrupt:
            pass
        finally:
            adc.close()

if __name__ == '__main__':
    main()

How the Drivers Work

The BH1750 is unusual because it has no registers: you send one opcode byte and then read two bytes. Using write_byte_data would insert a register byte that the chip does not expect, so the code uses i2c_rdwr with i2c_msg objects to send exactly the bytes from the datasheet. The raw count divided by 1.2 gives lux in the default measurement-time setting.

The MCP3008 is a full-duplex SPI device. While the Pi clocks out three bytes, the ADC clocks three bytes back. The first byte holds the start bit, the second selects single-ended mode and the channel, and the 10-bit result arrives split across the last two returned bytes. xfer2 keeps chip-enable low for the whole transfer, which the MCP3008 requires.

The CSV file is opened in append mode and flushed after every row, so a reboot or power cut loses at most one reading. Open it later in LibreOffice Calc or Excel, or load it with pandas, to plot light level against the potentiometer voltage. To sample faster, reduce the sleep time; the BH1750 in high-resolution mode needs roughly 120 ms per conversion, so readings faster than that simply repeat the previous value.

Troubleshooting

  • i2cdetect shows nothing: SDA and SCL are swapped, the module has no power, or I2C is not enabled. Check ls /dev/i2c-1.
  • Remote I/O error (errno 121): the address in the code does not match the one shown by i2cdetect.
  • MCP3008 always returns 0 or 1023: MISO and MOSI are swapped, or CS is on CE1 while the code opens device 0.
  • Readings jump around: add a 100 nF capacitor across the MCP3008 supply pins and keep analogue wires short.
  • Permission denied on /dev/spidev0.0: your user must be in the spi and i2c groups; accounts created by Imager already are.

Summary

Enable I2C and SPI with raspi-config, confirm devices with i2cdetect and /dev/spidev0.0, then talk to them from Python with smbus2 and spidev. The BH1750 shows how to handle a register-less I2C device with raw messages, and the MCP3008 shows how a full-duplex SPI transfer returns data. With these two patterns you can read almost any sensor datasheet and write a working driver for it.