If you are building a dedicated writing machine, backlit LCDs cause eye strain and invite distractions. A hardware-integrated Raspberry Pi text editor using an e-ink display solves both problems. By pairing a Raspberry Pi Zero 2 W with a 7.5-inch e-Paper HAT and a mechanical keyboard, you get a zero-distraction, high-contrast typing terminal that sips power and saves your retinas.
This guide walks through the exact hardware BOM, SPI pin mapping, and the Python code required to capture raw keyboard input and render it to the e-ink framebuffer. We will also cover the specific SPI and evdev permission errors that inevitably trip up embedded Linux builds.
Time to Build: 2 hours (Hardware assembly + OS configuration + Python scripting).
Hardware BOM and Pin Mapping
This build specifically targets the Raspberry Pi Zero 2 W. We use the Zero 2 W instead of the Pi 4 or 5 because its 512MB RAM is more than enough for a headless text buffer, and its low power draw allows for battery-powered field use.
Parts List
- Compute: Raspberry Pi Zero 2 W (with pre-soldered 40-pin GPIO header)
- Display: Waveshare 7.5" e-Paper V2 (800x480 resolution, SPI interface)
- Input: Any standard USB mechanical keyboard (e.g., Keychron K2 or generic 60% layout)
- Adapters: Micro-USB to USB-A OTG cable, Micro-HDMI to Mini-HDMI (for initial headless setup only)
- Storage: 32GB MicroSD card (Class 10, UHS-I)
SPI Pin Mapping Table
The Waveshare e-Paper HAT plugs directly onto the 40-pin header, but if you are wiring it manually via jumper cables to keep the profile thin, use this exact BCM-to-Physical pin mapping. The Zero 2 W only exposes one hardware SPI bus (SPI0).
| e-Paper Pin Function | Pi Zero 2 W BCM GPIO | Physical Pin # | Notes / Constraints |
|---|---|---|---|
| VCC | 3.3V Power | 1 or 17 | Do NOT use 5V; logic level is 3.3V |
| GND | Ground | 6, 9, 14, etc. | Common ground required |
| DIN (MOSI) | GPIO 10 | 19 | SPI0 MOSI |
| CLK (SCK) | GPIO 11 | 23 | SPI0 SCLK |
| CS | GPIO 8 | 24 | SPI0 CE0 (Chip Enable) |
| DC | GPIO 25 | 22 | Data/Command selection |
| RST | GPIO 17 | 11 | Active low reset |
| BUSY | GPIO 24 | 18 | High when display is updating |
E-Ink Refresh Modes and Timing Specs
Before writing the code, you must understand how e-ink updates. Unlike an LCD that refreshes at 60Hz, e-ink physically moves ink capsules. Pushing a full screen update on every keystroke will result in a 3-second delay and severe flickering. The Waveshare 7.5" V2 supports multiple Look-Up Table (LUT) modes. You must choose the right mode for your text editor's rendering loop.
| Refresh Mode | Update Time | Flicker | Ghosting Risk | Best Use Case in Text Editor |
|---|---|---|---|---|
| Full Refresh | ~3.0 seconds | Severe (Black/White flash) | None (Clears screen) | Initial boot, clearing document, or saving file |
| Partial Refresh | ~0.3 seconds | None | High (Accumulates over time) | Typing single characters, moving cursor |
| Fast Mode | ~0.4 seconds | Mild | Medium | Rapid scrolling through long text buffers |
| 4-Gray Mode | ~1.5 seconds | Moderate | Low | Rendering markdown headers or syntax highlighting |
Source: Waveshare 7.5" e-Paper HAT Wiki
Pro-Tip: For a typing interface, use Partial Refresh for keystrokes, but force a Full Refresh every 50th keystroke or when the user hits the spacebar. This prevents the "ghosting" effect where faint remnants of previous characters build up on the display.
Software Setup and Python Code
We are running Raspberry Pi OS Lite (Bookworm or newer). Desktop environments consume too much RAM and introduce input lag. We will use Python 3 with Pillow for rendering text to an image buffer, and evdev to read raw keystrokes directly from the Linux input subsystem.
Step 1: Enable SPI and Install Dependencies
- Run
sudo raspi-config, navigate to Interface Options, and enable SPI. - Verify SPI is active by checking for
/dev/spidev0.0usingls /dev/spi*. - Install the required Python libraries:
sudo apt update && sudo apt install python3-pil python3-pip git
pip3 install evdev spidev RPi.GPIO - Clone the Waveshare e-Paper library:
git clone https://github.com/waveshare/e-Paper.git
Step 2: The Python Text Editor Script
Save the following code as eink_editor.py. This script initializes the display, maps the exact GPIO pins, listens for USB keyboard input via evdev, and renders the text buffer to the screen.
import sys
import os
import time
import string
from evdev import InputDevice, categorize, ecodes
from PIL import Image, ImageDraw, ImageFont
# Import Waveshare drivers (ensure e-Paper/RaspberryPi_JetsonNano/python/lib is in PYTHONPATH)
from waveshare_epd import epd7in5_V2
# --- PIN DEFINITIONS (BCM Numbering for Pi Zero 2 W) ---
# These map to the physical pins defined in the BOM table
PIN_CONFIG = {
'RST_PIN': 17, # Physical Pin 11
'DC_PIN': 25, # Physical Pin 22
'CS_PIN': 8, # Physical Pin 24 (SPI CE0)
'BUSY_PIN': 24, # Physical Pin 18
}
# --- CONFIGURATION ---
KEYBOARD_DEVICE_PATH = '/dev/input/event0' # Adjust if multiple keyboards are attached
FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'
FONT_SIZE = 24
MAX_CHARS_PER_LINE = 45
MAX_LINES = 18
def init_display():
try:
epd = epd7in5_V2.EPD()
epd.init()
epd.Clear()
return epd
except FileNotFoundError as e:
print(f"FATAL: {e}. SPI is not enabled or /dev/spidev0.0 is missing.")
sys.exit(1)
except Exception as e:
print(f"FATAL: Display initialization failed: {e}")
sys.exit(1)
def get_keyboard():
try:
dev = InputDevice(KEYBOARD_DEVICE_PATH)
print(f"Listening to keyboard: {dev.name}")
return dev
except PermissionError as e:
print(f"FATAL: {e}. Run with sudo or add user to 'input' group via udev.")
sys.exit(1)
except FileNotFoundError as e:
print(f"FATAL: {e}. Keyboard not found at {KEYBOARD_DEVICE_PATH}.")
sys.exit(1)
def render_text(epd, text_buffer, cursor_pos):
# Create a blank white image
image = Image.new('1', (epd.width, epd.height), 255)
draw = ImageDraw.Draw(image)
try:
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
except IOError:
font = ImageFont.load_default()
print("Warning: TTF font not found, using default bitmap font.")
y_offset = 10
line_height = FONT_SIZE + 8
# Render text lines
for i, line in enumerate(text_buffer):
if i >= MAX_LINES:
break
draw.text((10, y_offset + (i * line_height)), line, font=font, fill=0)
# Draw simple block cursor
current_line_idx = min(len(text_buffer) - 1, MAX_LINES - 1)
current_line_text = text_buffer[current_line_idx]
bbox = font.getbbox(current_line_text[:cursor_pos])
cursor_x = 10 + bbox[2]
cursor_y = y_offset + (current_line_idx * line_height)
draw.rectangle([cursor_x, cursor_y, cursor_x + 12, cursor_y + FONT_SIZE], fill=0)
# Push to display using Partial Refresh for speed (if supported by specific LUT)
# Fallback to standard display() for broad compatibility
epd.display(epd.getbuffer(image))
def main():
epd = init_display()
keyboard = get_keyboard()
text_buffer = [""]
cursor_pos = 0
keystroke_count = 0
render_text(epd, text_buffer, cursor_pos)
try:
for event in keyboard.read_loop():
if event.type == ecodes.EV_KEY and event.value == 1: # Key down
keystroke_count += 1
key_event = categorize(event)
if key_event.keycode == 'KEY_ENTER':
# Handle newline
remainder = text_buffer[-1][cursor_pos:]
text_buffer[-1] = text_buffer[-1][:cursor_pos]
text_buffer.append(remainder)
cursor_pos = 0
elif key_event.keycode == 'KEY_BACKSPACE':
if cursor_pos > 0:
line = text_buffer[-1]
text_buffer[-1] = line[:cursor_pos-1] + line[cursor_pos:]
cursor_pos -= 1
elif len(text_buffer) > 1:
# Merge with previous line
prev_line = text_buffer.pop(-2)
cursor_pos = len(prev_line)
text_buffer[-1] = prev_line + text_buffer[-1]
elif key_event.keycode == 'KEY_SPACE':
text_buffer[-1] = text_buffer[-1][:cursor_pos] + ' ' + text_buffer[-1][cursor_pos:]
cursor_pos += 1
# Force full refresh on spacebar to clear ghosting
if keystroke_count % 10 == 0:
epd.init()
epd.Clear()
elif key_event.keycode.startswith('KEY_') and len(key_event.keycode) == 6:
# Basic letter/number mapping
char = key_event.keycode[-1].lower()
if char in string.ascii_lowercase + string.digits:
text_buffer[-1] = text_buffer[-1][:cursor_pos] + char + text_buffer[-1][cursor_pos:]
cursor_pos += 1
render_text(epd, text_buffer, cursor_pos)
except KeyboardInterrupt:
print("Shutting down editor...")
finally:
epd.sleep()
if __name__ == '__main__':
main()
Debugging Common SPI and Input Errors
Embedded Linux is unforgiving with hardware permissions. When your script crashes on boot, do not guess. Look at the exact traceback. Here are the first three things to check when it fails, mapped to their specific error strings.
1. The SPI Device Missing Error
Exact Error String: FileNotFoundError: [Errno 2] No such file or directory: '/dev/spidev0.0'
Ranked Causes & Fixes:
- SPI not enabled in config: On newer Raspberry Pi OS versions, the config file moved. Edit
/boot/firmware/config.txt(not/boot/config.txt) and ensuredtparam=spi=onis present and uncommented. Reboot. - SPI kernel module blacklisted: Run
lsmod | grep spi_bcm2835. If it returns nothing, runsudo modprobe spi_bcm2835to load it manually and checkdmesgfor hardware conflicts. - Loose FPC Ribbon Cable: The Waveshare HAT uses a fragile 24-pin FPC cable. Ensure the black locking collar on the Pi Zero's connector is fully depressed and the cable contacts face the correct direction (usually towards the PCB).
2. The Input Permission Denied Error
Exact Error String: PermissionError: [Errno 13] Permission denied: '/dev/input/event0'
Ranked Causes & Fixes:
- Running without sudo: The
evdevlibrary requires raw access to input devices. The quick fix is runningsudo python3 eink_editor.py. - Missing udev rules (The Proper Fix): Running UI scripts as root is bad practice. Create a udev rule to grant the
inputgroup read access. Create/etc/udev/rules.d/99-input.rulescontaining:
KERNEL=="event*", NAME="input/%k", MODE="0660", GROUP="input"
Then runsudo udevadm control --reload-rules && sudo udevadm triggerand add your user to the group:sudo usermod -aG input $USER. Log out and back in. - Wrong Event Node: If you have multiple USB devices, the keyboard might be
/dev/input/event1. Runcat /proc/bus/input/devicesto find the correct event handler for your keyboard.
3. The SPI Buffer Limit Error
Exact Error String: OSError: [Errno 22] Invalid argument (usually occurring inside spidev.spi.xfer2())
Cause: The default Linux SPI buffer size is often 4096 bytes. An 800x480 1-bit image is 48,000 bytes. Pushing it in one transaction crashes the kernel driver.
Fix: Increase the SPI buffer size by adding spidev.bufsiz=65536 to the end of your /boot/firmware/cmdline.txt file (on the same line, separated by a space). Reboot.
Source: Raspberry Pi Configuration Documentation
Extending or Simplifying the Build
Depending on your end goal, you might want to strip this project down to its bare essentials or scale it up into a portable cyberdeck.
How to Simplify the Build
If writing custom Python rendering loops and debugging evdev permissions sounds like overkill, ditch the e-ink display entirely.
- Flash standard Raspberry Pi OS Desktop to a Pi 4 or Pi 5.
- Connect a standard HDMI monitor.
- Open the terminal and use
nano,vim, or installgedit. - Use a tool like
tmuxto manage your writing sessions. This removes all hardware integration headaches and gives you a fully featured Linux text editor in 10 minutes.
How to Extend the Build
If you want to take this Raspberry Pi text editor into the field as a standalone typewriter:
- Power: Add a PiSugar 3 Plus battery HAT. It plugs directly into the GPIO, provides an I2C fuel gauge, and includes a physical power button to safely shut down the Pi Zero 2 W without corrupting the SD card.
- Storage: Mount a USB thumb drive via the OTG port and use
udevrules to auto-mount it, saving your text files directly to external storage so you can pull them off on a main PC. - Timekeeping: The Pi Zero 2 W lacks an onboard RTC (Real Time Clock). Solder a DS3231 I2C module to pins 3 and 5 so your document timestamps remain accurate when the device is powered off in the field.
Building a dedicated hardware text editor forces you to confront the realities of embedded Linux—from SPI buffer limits to raw input permissions. But the result is a focused, flicker-free writing tool that feels entirely your own.






