Objective and use case
What you’ll build: A live system dashboard on Raspberry Pi 5 that displays real-time system health and network telemetry on a 2.9-inch e-Paper display using Python and SPI for efficient updates.
Why it matters / Use cases
- Monitor system performance metrics like CPU temperature and memory usage directly on the e-Paper display.
- Visualize network statistics such as packet loss and latency for troubleshooting connectivity issues.
- Provide a low-power solution for remote monitoring applications, ideal for IoT deployments.
- Utilize the e-Paper display’s readability in various lighting conditions for outdoor installations.
Expected outcome
- Real-time updates of system metrics with a refresh rate of less than 1 second.
- Power consumption under 1W during operation, ensuring energy efficiency.
- Display of critical alerts when CPU temperature exceeds 75°C or memory usage exceeds 80%.
- Network latency measurements displayed with an accuracy of ±5ms.
Audience: Intermediate developers; Level: Advanced Hands-On
Architecture/flow: Raspberry Pi 5 communicates with the Waveshare 2.9-inch e-Paper display over SPI, fetching data from system sensors and network interfaces.
Advanced Hands‑On: SPI e‑Paper Live Dashboard on Raspberry Pi 5 + Waveshare 2.9″ e‑Paper HAT
Build a responsive, low‑power system dashboard that renders live system health and network telemetry on a black‑and‑white 2.9″ e‑Paper display. You’ll drive the display over SPI from a Raspberry Pi 5 using Python 3.11 on Raspberry Pi OS Bookworm (64‑bit), leveraging partial refresh to keep updates quick and reduce ghosting.
The objective: spi‑epaper‑live‑dashboard on the exact device model “Raspberry Pi 5 + Waveshare 2.9″ e‑Paper HAT”.
Prerequisites
- Hardware
- Raspberry Pi 5 (4 GB or 8 GB recommended)
- Waveshare 2.9″ e‑Paper HAT (black/white; 296×128 px)
- Reliable 5V USB‑C power supply for Pi 5 (3A recommended)
- microSD (≥16 GB, A1 or better)
-
Optional: USB keyboard/HDMI for first boot, or SSH
-
OS and tools
- Raspberry Pi OS Bookworm 64‑bit (latest, with Linux 6.x)
- Python 3.11 (default on Bookworm)
-
Internet connectivity for package installs
-
Skills
- Comfortable with Linux shell and editing files
- Familiarity with GPIO pinouts and SPI
Materials (with exact model)
- 1× Raspberry Pi 5
- 1× Waveshare 2.9″ e‑Paper HAT (HAT form factor for RPi 40‑pin header)
- microSD card (≥16GB)
- USB‑C 5V/3A PSU
- Optional standoffs/screws for the HAT
Notes:
– The Waveshare 2.9″ e‑Paper HAT uses SPI0 and three control pins (DC, RST, BUSY). Out of the box, it maps to the Pi 40‑pin header.
Setup/Connection
1) OS install and first boot
- Flash Raspberry Pi OS Bookworm 64‑bit using Raspberry Pi Imager.
- On first boot, configure locale, Wi‑Fi, and enable SSH if needed.
2) Enable SPI interface
You can enable SPI with raspi‑config or by editing /boot/firmware/config.txt.
- Using raspi‑config (interactive):
sudo raspi-config
# Finish and reboot
sudo reboot
- Or edit config.txt directly:
sudo nano /boot/firmware/config.txt
Ensure this line exists (uncomment or add):
dtparam=spi=on
Save, exit, and reboot:
sudo reboot
After reboot, verify:
ls -l /dev/spidev0.0
# Expect: /dev/spidev0.0 device present
Optional: verify kernel module:
lsmod | grep spidev || echo "spidev not listed (may be built-in)"
3) Physical attachment and pin mapping
Mount the Waveshare 2.9″ e‑Paper HAT onto the 40‑pin GPIO header of the Raspberry Pi 5. The HAT is keyed to mate directly; no jumper wires are needed. For reference, the e‑Paper HAT uses the following pins by default:
| Signal | BCM GPIO | Physical Pin | Notes |
|---|---|---|---|
| 3.3V | — | 1 | Board power for logic |
| GND | — | 6 | Ground reference |
| SCLK | GPIO11 | 23 | SPI0 SCLK |
| MOSI | GPIO10 | 19 | SPI0 MOSI |
| MISO | GPIO9 | 21 | SPI0 MISO (not used by some panels) |
| CS0 | GPIO8 | 24 | SPI0 CE0 (display chip select) |
| DC | GPIO25 | 22 | Data/Command select |
| RST | GPIO17 | 11 | e‑Paper hardware reset |
| BUSY | GPIO24 | 18 | Panel busy status (low=busy on most variants) |
Important:
– The HAT aligns and routes these pins internally; do not double‑wire them unless you change the defaults.
– Double‑check that the HAT fully seats on the 40‑pin header.
4) Update system packages
sudo apt update
sudo apt full-upgrade -y
sudo reboot
5) Python 3.11 virtual environment and dependencies
We’ll create a project directory under /home/pi/spi-epaper-live-dashboard and install packages in a venv.
# Create project directory
mkdir -p /home/pi/spi-epaper-live-dashboard
cd /home/pi/spi-epaper-live-dashboard
# Base tools
sudo apt install -y python3.11-venv python3-pip python3-dev \
libatlas-base-dev libjpeg-dev zlib1g-dev \
fonts-dejavu-core \
git
# Create and activate venv
python3 -m venv .venv
source .venv/bin/activate
# Upgrade pip tooling
pip install --upgrade pip wheel setuptools
# Install runtime Python deps
pip install pillow psutil gpiozero spidev waveshare-epd
Notes:
– gpiozero interacts with pins via the OS; spidev allows raw SPI access and is a waveshare‑epd dependency.
– pillow handles image buffers and font rendering.
– psutil supplies CPU/memory/network metrics.
– waveshare‑epd provides panel drivers; we’ll use epd2in9_V2.
Check installed versions:
pip show pillow psutil gpiozero spidev waveshare-epd
python -V
6) Permissions
Ensure your user is in the spi group:
sudo usermod -aG spi $USER
# Log out/in or reboot to apply
Full Code
Create the main application at /home/pi/spi-epaper-live-dashboard/dashboard.py.
This script:
– Initializes the Waveshare 2.9″ e‑Paper (296×128 px) in landscape.
– Renders a live dashboard: time, CPU load and temperature, memory and disk usage, IP address, and network throughput.
– Uses a partial refresh most cycles for speed, with periodic full refresh to de‑ghost.
#!/usr/bin/env python3
# /home/pi/spi-epaper-live-dashboard/dashboard.py
# Raspberry Pi 5 + Waveshare 2.9" e-Paper HAT
# spi-epaper-live-dashboard (Bookworm 64-bit, Python 3.11)
import os
import sys
import time
import math
import signal
import socket
import logging
from datetime import datetime
import psutil
from gpiozero import CPUTemperature
from PIL import Image, ImageDraw, ImageFont
# Waveshare driver for 2.9" V2 (296x128), black/white
from waveshare_epd import epd2in9_V2
APP_DIR = os.path.dirname(os.path.abspath(__file__))
FONT_SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_MONO = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
# Layout constants
DARK = 0 # black pixel on B/W panel
LIGHT = 255 # white pixel
W = 296
H = 128
# Update policy
PARTIAL_INTERVAL_SEC = 5 # partial update every 5 seconds
FULL_REFRESH_EVERY = 24 # after 24 partial cycles (~2 minutes), do a full refresh
# Panels prefer to clear after ~5 minutes of partial updates to avoid ghosting
# This policy trades responsiveness for panel health.
log = logging.getLogger("dashboard")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s",
)
RUN = True
def handle_exit(signum, frame):
global RUN
RUN = False
signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit)
def get_ip():
try:
# Attempt to get default route IP by creating a UDP socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.2)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
# Fallback to hostname resolution
try:
return socket.gethostbyname(socket.gethostname())
except Exception:
return "0.0.0.0"
def human_bytes(n):
# Format bytes/sec or totals
units = ["B", "KB", "MB", "GB", "TB"]
f = float(n)
u = 0
while f >= 1024 and u < len(units)-1:
f /= 1024.0
u += 1
return f"{f:.1f}{units[u]}"
def draw_bar(draw, x, y, w, h, frac, label=None, value_text=None):
# Background
draw.rectangle((x, y, x+w, y+h), fill=LIGHT, outline=DARK, width=1)
# Fill proportionally
fill_w = int(max(0, min(1.0, frac)) * (w-2))
draw.rectangle((x+1, y+1, x+1+fill_w, y+h-1), fill=DARK)
# Optional text overlay
if label:
draw.text((x+4, y-18), label, font=FONT_SMALL, fill=DARK)
if value_text:
tw, th = draw.textsize(value_text, font=FONT_SMALL)
draw.text((x+w-tw-4, y-18), value_text, font=FONT_SMALL, fill=DARK)
def layout_dashboard(epd, metrics, do_full=False):
# Create a fresh canvas (landscape). waveshare drivers often use (H, W) swap.
image = Image.new('1', (W, H), LIGHT)
draw = ImageDraw.Draw(image)
# Title and time
now = datetime.now()
title = "LIVE DASH"
draw.text((8, 6), title, font=FONT_BOLD, fill=DARK)
timestr = now.strftime("%Y-%m-%d %H:%M:%S")
tw, th = draw.textsize(timestr, font=FONT_MED)
draw.text((W - tw - 8, 6), timestr, font=FONT_MED, fill=DARK)
# CPU row
cpu_text = f"CPU {metrics['cpu_percent']:>4.1f}% {metrics['cpu_temp']:>4.1f}°C {metrics['freq_mhz']:>4.0f}MHz"
draw.text((8, 30), cpu_text, font=FONT_MED, fill=DARK)
draw_bar(draw, 8, 54, 160, 16, metrics['cpu_percent']/100.0, label="CPU", value_text=f"{metrics['cpu_percent']:.0f}%")
# Memory row
mem = metrics['mem']
mem_text = f"MEM {mem['used_mb']:>4.0f}/{mem['total_mb']:>4.0f}MB"
draw.text((8, 80), mem_text, font=FONT_MED, fill=DARK)
draw_bar(draw, 8, 102, 160, 16, mem['used_mb']/mem['total_mb'], label="RAM", value_text=f"{mem['used_mb']:.0f}MB")
# Right column: Disk, Net, IP
x0 = 180
draw.text((x0, 30), f"DISK {metrics['disk']['used_gb']:.1f}/{metrics['disk']['total_gb']:.1f}GB", font=FONT_MED, fill=DARK)
draw.text((x0, 50), f"NET ↓{metrics['net']['rx']} ↑{metrics['net']['tx']}", font=FONT_MED, fill=DARK)
draw.text((x0, 70), f"IP {metrics['ip']}", font=FONT_MED, fill=DARK)
draw.text((x0, 90), f"LOAD {metrics['loadavg']}", font=FONT_MED, fill=DARK)
draw.text((x0, 110), "REF FULL" if do_full else "REF PART", font=FONT_MED, fill=DARK)
return image
def collect_metrics(prev_net):
cpu = psutil.cpu_percent(interval=None)
try:
temp = CPUTemperature().temperature
except Exception:
temp = psutil.sensors_temperatures().get('cpu_thermal', [{}])[0].get('current', 0.0)
freq = psutil.cpu_freq()
freq_mhz = freq.current if freq else 0.0
vm = psutil.virtual_memory()
mem = {
"total_mb": vm.total / (1024*1024),
"used_mb": (vm.total - vm.available) / (1024*1024),
}
du = psutil.disk_usage("/")
disk = {
"total_gb": du.total / (1024*1024*1024),
"used_gb": du.used / (1024*1024*1024),
}
ip = get_ip()
n = psutil.net_io_counters()
if prev_net is None:
rx_rate = tx_rate = 0.0
else:
dt = max(1e-6, time.time() - prev_net['t'])
rx_rate = (n.bytes_recv - prev_net['rx']) / dt
tx_rate = (n.bytes_sent - prev_net['tx']) / dt
load1, load5, load15 = os.getloadavg()
metrics = {
"cpu_percent": cpu,
"cpu_temp": temp,
"freq_mhz": freq_mhz,
"mem": mem,
"disk": disk,
"ip": ip,
"net": {
"rx": human_bytes(rx_rate) + "/s",
"tx": human_bytes(tx_rate) + "/s"
},
"loadavg": f"{load1:.2f},{load5:.2f},{load15:.2f}",
}
new_prev = {"rx": n.bytes_recv, "tx": n.bytes_sent, "t": time.time()}
return metrics, new_prev
def main():
global RUN
# Load fonts
size_small = 12
size_med = 16
size_bold = 20
global FONT_SMALL, FONT_MED, FONT_BOLD
try:
FONT_SMALL = ImageFont.truetype(FONT_MONO, size_small)
FONT_MED = ImageFont.truetype(FONT_SANS, size_med)
FONT_BOLD = ImageFont.truetype(FONT_SANS, size_bold)
except Exception as e:
log.warning("Falling back to default PIL fonts: %s", e)
FONT_SMALL = ImageFont.load_default()
FONT_MED = ImageFont.load_default()
FONT_BOLD = ImageFont.load_default()
log.info("Initializing e-Paper...")
epd = epd2in9_V2.EPD()
epd.init()
epd.Clear(0xFF)
# Base frame to reduce flashing on partial updates
base_image = Image.new('1', (W, H), LIGHT)
epd.displayPartBaseImage(epd.getbuffer(base_image))
counter = 0
prev_net = None
try:
while RUN:
do_full = (counter % FULL_REFRESH_EVERY == 0)
metrics, prev_net = collect_metrics(prev_net)
canvas = layout_dashboard(epd, metrics, do_full=do_full)
if do_full:
log.info("Full refresh")
epd.init() # re-init full update waveform
epd.display(epd.getbuffer(canvas))
# Reset base after full refresh to reduce ghosting drift
epd.displayPartBaseImage(epd.getbuffer(canvas))
else:
# Partial refresh
epd.displayPartial(epd.getbuffer(canvas))
counter += 1
# Sleep until next partial update
for _ in range(PARTIAL_INTERVAL_SEC * 10):
if not RUN:
break
time.sleep(0.1)
except Exception as e:
log.exception("Unhandled error: %s", e)
finally:
# Put panel to sleep to preserve lifetime
try:
epd.init()
epd.sleep()
except Exception as e:
log.warning("Failed to sleep panel cleanly: %s", e)
log.info("Done.")
if __name__ == "__main__":
main()
Optional small hardware check script at /home/pi/spi-epaper-live-dashboard/check_spi.py:
#!/usr/bin/env python3
# Minimal SPI sanity check (does not drive panel fully)
import spidev
s = spidev.SpiDev()
s.open(0, 0) # bus 0, CE0
s.max_speed_hz = 4000000
s.mode = 0
print("spidev opened:", s)
# Read back nothing meaningful from panel, but at least ensure write call succeeds:
s.xfer2([0x00])
s.close()
print("SPI xfer2 OK at 4MHz, mode 0")
Make both scripts executable:
chmod +x /home/pi/spi-epaper-live-dashboard/dashboard.py
chmod +x /home/pi/spi-epaper-live-dashboard/check_spi.py
Build/Flash/Run Commands
1) Create and activate the environment
cd /home/pi/spi-epaper-live-dashboard
source .venv/bin/activate
2) Quick SPI verification
python ./check_spi.py
Expected: prints that SPI opened and xfer2 OK. If not, check SPI enabling and permissions.
3) First dashboard run (foreground)
python ./dashboard.py
The display should clear, then show the dashboard. Every 5 seconds it should partially update. Every ~2 minutes it should do a full refresh (brief flash).
Stop with Ctrl+C.
4) Optional: systemd service for auto‑start at boot
Create /etc/systemd/system/epaper-dashboard.service:
sudo tee /etc/systemd/system/epaper-dashboard.service >/dev/null <<'UNIT'
[Unit]
Description=SPI e-Paper Live Dashboard (Waveshare 2.9") on Raspberry Pi 5
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/spi-epaper-live-dashboard
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/spi-epaper-live-dashboard/.venv/bin/python /home/pi/spi-epaper-live-dashboard/dashboard.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
Reload, enable, and start:
sudo systemctl daemon-reload
sudo systemctl enable epaper-dashboard.service
sudo systemctl start epaper-dashboard.service
sudo systemctl status epaper-dashboard.service --no-pager
Stop/disable if needed:
sudo systemctl stop epaper-dashboard.service
sudo systemctl disable epaper-dashboard.service
Step‑by‑step Validation
Follow this checklist to validate each layer before blaming the next.
1) OS and Python
– Verify 64‑bit Bookworm and Python 3.11:
uname -m && lsb_release -ds
python3 -V
Expect: aarch64, “Raspberry Pi OS Bookworm”, and Python 3.11.x.
2) SPI enabled and device nodes present
– Confirm /dev/spidev0.0:
ls -l /dev/spidev0.0
- Confirm dtparam is active:
grep -E '^dtparam=spi=on' /boot/firmware/config.txt
- Check that your user is in the spi group:
id -nG | tr ' ' '\n' | grep -x spi || echo "User not in spi group"
3) HAT seating and pin mapping
– Press the HAT gently to ensure it fully seats on the 40‑pin header.
– Ensure no standoffs short pins.
– If using jumpers instead of HAT, confirm connections match the table earlier exactly.
4) Python environment and dependencies
– Activate venv:
source /home/pi/spi-epaper-live-dashboard/.venv/bin/activate
- Check packages:
python -c "import PIL,psutil,spidev,gpiozero; print('deps OK')"
python -c "from waveshare_epd import epd2in9_V2; print('waveshare-epd OK')"
5) SPI sanity
– Run check script:
python /home/pi/spi-epaper-live-dashboard/check_spi.py
6) First image on panel
– Run the dashboard:
python /home/pi/spi-epaper-live-dashboard/dashboard.py
Observe:
– Initial clear to white.
– Dashboard appears with time, CPU/Temp, RAM, DISK, NET, IP, LOAD.
– Partial updates every ~5 seconds show changing CPU percentage and network throughput.
– Full refresh every ~2 minutes briefly flashes, then restores crisp text.
7) Service mode
– Start the systemd service and confirm it holds through reboots.
Troubleshooting
- Symptom: python import error for waveshare_epd
- Cause: Package not installed in venv.
-
Fix: Activate venv, then pip install waveshare-epd. Verify with import test.
-
Symptom: PermissionError: [Errno 13] Permission denied: ‘/dev/spidev0.0’
- Cause: SPI not enabled or user not in spi group.
-
Fix: Enable in raspi-config or config.txt, add user to spi group, reboot.
-
Symptom: /dev/spidev0.0 missing
- Cause: SPI disabled or kernel missing spidev device.
-
Fix: Ensure dtparam=spi=on in /boot/firmware/config.txt, reboot. Check dmesg | grep -i spi.
-
Symptom: e‑Paper stuck white or black, BUSY never clears
- Causes:
- BUSY/DC/RST pins mismatched (only if manually wired).
- Incomplete init or previous crash during update.
-
Fix:
- Power‑cycle the Pi to hard reset the panel.
- Confirm pin mappings per table.
- Ensure stable 3.3V rail; avoid undervoltage (check dmesg for “Under-voltage detected!”).
-
Symptom: Ghosting accumulates (shadows of old content)
- Cause: Too many partial updates without full refresh.
-
Fix: Increase rate of full refresh (reduce FULL_REFRESH_EVERY). Consider calling epd.Clear(0xFF) every 5–10 minutes.
-
Symptom: Fonts look jagged or too small
-
Fix: Use a heavier/bold font or larger size. Ensure fonts-dejavu-core is installed. Adjust FONT_SANS/MONO sizes in the script.
-
Symptom: High CPU load from Python drawing
- Cause: Excessively frequent refresh or large anti‑aliased fonts.
-
Fix: Keep PARTIAL_INTERVAL_SEC ≥ 3–5 seconds. Avoid complex graphics. Stick to 1‑bit images.
-
Symptom: Network throughput always shows 0.0B/s
- Cause: Baseline sample is the first call; rate needs two measurements.
-
Fix: Wait one cycle; subsequent partial updates will show non‑zero values.
-
Symptom: ModuleNotFoundError: _lgpio or RPi.GPIO warnings
- Cause: gpiozero backend fallback.
-
Fix: In Bookworm, gpiozero should work out of the box. If not, try installing python3-lgpio via apt for system Python, or avoid gpiozero by using psutil.sensors_temperatures directly.
-
Symptom: Service runs but screen doesn’t update
- Causes:
- Service runs before SPI device is ready.
- Wrong WorkingDirectory or venv path.
-
Fix:
- Add After=spi.target (if available) or a small ExecStartPre sleep.
- Double-check ExecStart path and WorkingDirectory.
-
Diagnostic commands
- Check systemd logs:
journalctl -u epaper-dashboard.service -e --no-pager
- Inspect pin states (BUSY high/low):
raspi-gpio get 24 25 17
- Confirm SPI bus activity:
sudo strace -f -e trace=openat /home/pi/spi-epaper-live-dashboard/.venv/bin/python /home/pi/spi-epaper-live-dashboard/dashboard.py
Improvements
- Content sources
- Subscribe to MQTT topics and render application metrics or sensor values.
-
Add local sensor widgets (e.g., I2C temperature/humidity) and rotate pages every minute.
-
Rendering quality and lifetime
- Implement scheduled deep clears (epd.Clear(0xFF)) every 10 minutes to purge ghosting.
-
Use dithering for small icons or QR codes while preserving 1‑bit output.
-
Performance tuning
- Batch drawing into regions; if your panel/driver supports partial window updates, restrict refresh to changing rectangles to reduce flicker and extend panel life.
-
Pre‑render static assets (labels, lines) into a base image and composite only the dynamic overlays each cycle.
-
Robustness
- Wrap updates with a watchdog; if an exception occurs, reset the panel (RST pin toggled by driver init).
-
Add graceful backoff when network metrics are unavailable.
-
Deployment
- Use a Python packaging structure and a lock file (pip-tools or uv) to pin versions and ensure reproducibility.
-
Containerize on Bookworm with systemd‑nspawn or podman if isolating dependencies is important.
-
Power/use case expansions
- Pair with a battery HAT and use cron or a button to wake, refresh a snapshot, then sleep the Pi for low‑duty signage.
-
Integrate with Home Assistant via REST/MQTT for smart home status display.
-
UI/UX
- Implement page flipping via a GPIO button (gpiozero Button on BCM5) to cycle dashboards.
- Add large glyphs for glanceable status (e.g., WIFI strength bars, USB device count, service health).
Final Checklist
- Hardware
- Raspberry Pi 5 powered with a 5V/3A supply
- Waveshare 2.9″ e‑Paper HAT seated on the 40‑pin header
-
No shorts, secure mechanical mounting
-
OS and interfaces
- Raspberry Pi OS Bookworm 64‑bit installed
- SPI enabled: dtparam=spi=on in /boot/firmware/config.txt
- /dev/spidev0.0 present
-
User added to spi group
-
Python environment
- Project directory: /home/pi/spi-epaper-live-dashboard
- Virtual environment created: .venv present
- Dependencies installed in venv: pillow, psutil, gpiozero, spidev, waveshare-epd
-
Fonts installed: fonts-dejavu-core
-
Application
- dashboard.py executable and configured with correct font paths
- check_spi.py runs without errors
- Foreground run shows dashboard and periodic updates
-
Partial refresh cadence ~5s; full refresh ~2 min (configurable)
-
Service (optional)
- systemd unit at /etc/systemd/system/epaper-dashboard.service
- Enabled and started; persists across reboot
-
Logs clean in journalctl
-
Validation complete
- CPU %, temperature, RAM, disk, IP, load average, and net throughput values look reasonable
- Panel sleeps on exit; no persistent ghosting after periodic deep clears
With this setup, your Raspberry Pi 5 + Waveshare 2.9″ e‑Paper HAT becomes a silent, low‑power SPI‑driven live dashboard suitable for desks, labs, or server racks—updating just enough to stay useful while preserving the panel’s lifetime.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



