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
| Feature | I2C | SPI |
|---|---|---|
| Wires | 2 (SDA, SCL) plus GND | 4 (MOSI, MISO, SCLK, CE) plus GND |
| Device selection | 7-bit address in each message | Separate 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 speed | 100 or 400 kHz | 1 MHz and above |
| Python library | smbus2 | spidev |
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 pin | Connect to Pi 4 | Physical pin |
|---|---|---|
| BH1750 VCC | 3.3 V | 1 |
| BH1750 GND | GND | 6 |
| BH1750 SDA | GPIO2 | 3 |
| BH1750 SCL | GPIO3 | 5 |
| BH1750 ADDR | GND (address 0x23) | - |
| MCP3008 VDD and VREF (16, 15) | 3.3 V | 17 |
| MCP3008 AGND and DGND (14, 9) | GND | 20 |
| MCP3008 CLK (13) | GPIO11 SCLK | 23 |
| MCP3008 DOUT (12) | GPIO9 MISO | 21 |
| MCP3008 DIN (11) | GPIO10 MOSI | 19 |
| MCP3008 CS (10) | GPIO8 CE0 | 24 |
| Potentiometer wiper | MCP3008 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
#!/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
spiandi2cgroups; 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.