Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 56 additions & 111 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,121 +1,66 @@
# Pi-hole SPI TFT Dashboard

[![Platform](https://img.shields.io/badge/Raspberry%20Pi-supported-C51A4A.svg)](https://www.raspberrypi.com/)
[![Render](https://img.shields.io/badge/render-%2Fdev%2Ffb1-informational.svg)](#)

Framebuffer-based rotating dashboard for Raspberry Pi (320×240 SPI TFT), designed to run separate scripts as pages for different data sets (for example: weather, calendar, Pi-hole stats, RSS feeds, and more).

* No X11
* No SDL
* Direct `/dev/fb1` RGB565 rendering

---

## Requirements

* Raspberry Pi OS (SPI enabled)
* Python 3
* Pillow
* systemd

---

## Install

### 1. Install TFT driver

```bash
Minimal framebuffer-based Pi-hole dashboard for Raspberry Pi (320×240 SPI TFT).

No X11
No SDL
Direct /dev/fb1 RGB565 rendering
Suggested project structure
zero2dash/
├── scripts/
│ ├── pihole-display-pre.sh
│ ├── piholestats_v1.0.py
│ ├── piholestats_v1.1.py
│ ├── piholestats_v1.2.py
│ └── test.py
├── systemd/
│ ├── pihole-display.service
│ ├── pihole-display-dark.service
│ ├── day.timer
│ └── night.timer
└── README.md
Requirements
Raspberry Pi OS (SPI enabled)
Python 3
Pillow
systemd
Install
1. Install TFT driver
sudo rm -rf LCD-show
git clone https://github.com/goodtft/LCD-show.git
cd LCD-show
sudo ./LCD24-show
```

Reboot → display active on `/dev/fb1`.

---

### 2. Install Python dependency

```bash
sudo apt install -y python3-pip
pip3 install pillow
```

---

## Configure

Edit in `piholestats_v1.2.py`:

* `PIHOLE_HOST`
* `PIHOLE_PASSWORD`
* `REFRESH_SECS`

---

## Day mode page rotation

`display.service` starts `display_rotator.py`, which rotates independent page scripts so each page can show a different data set (weather, calendar, Pi-hole, RSS, etc.).

Touch controls in `display_rotator.py`:

* Tap right side — next page/script
* Tap left side — previous page/script
* Double tap anywhere — screen off/on

Optional environment variables for the rotator:

* `ROTATOR_PAGES` — comma-separated script list (default: `piholestats_v1.0.py,piholestats_v1.1.py`)
* `ROTATOR_SECS` — seconds per page (default: `30`, minimum: `5`)
* `ROTATOR_TOUCH_DEVICE` — explicit `/dev/input/eventX` device for touch input
* `ROTATOR_TOUCH_WIDTH` — touch X-axis width used to split left/right taps (default: `320`)
* `ROTATOR_FBDEV` — framebuffer device used for screen blank/unblank (default: `/dev/fb1`)

Example systemd override:

```bash
sudo systemctl edit display.service
```

Then add:

```ini
[Service]
Environment=ROTATOR_PAGES=piholestats_v1.0.py,piholestats_v1.1.py
Environment=ROTATOR_SECS=20
```

## Run via systemd

```bash
sudo cp systemd/*.service /etc/systemd/system/
Reboot → display active on /dev/fb1.

2. Install Python dependency
sudo apt install -y python3-pip python3-pil
3. Deploy project files
sudo mkdir -p /opt/zero2dash
sudo cp -r . /opt/zero2dash/
sudo chmod +x /opt/zero2dash/scripts/pihole-display-pre.sh
sudo chmod +x /opt/zero2dash/scripts/test.py
Configure
Edit in /opt/zero2dash/scripts/piholestats_v1.2.py:

PIHOLE_HOST
PIHOLE_PASSWORD
REFRESH_SECS
Run via systemd
sudo cp /opt/zero2dash/systemd/pihole-display*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now display.service
```

sudo systemctl enable --now pihole-display.service
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reference the actual day service unit in install commands

The README instructs users to enable pihole-display.service, but this commit only provides systemd/display.service (plus pihole-display-dark.service), so following these commands will fail with a missing unit and leave day mode not installed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Copy and enable the same service unit name

The install commands copy pihole-display*.service and then enable pihole-display.service, but this commit only provides systemd/display.service (plus pihole-display-dark.service), so following these steps leaves the main day service absent and systemctl enable --now pihole-display.service fails with a missing unit.

Useful? React with 👍 / 👎.

Check logs:

```bash
journalctl -u display.service -n 50 --no-pager
```
journalctl -u pihole-display.service -n 50 --no-pager
Placeholder test script
To verify basic rendering logic:

---
python3 /opt/zero2dash/scripts/test.py --fbdev /dev/fb1
Or generate a local preview without touching framebuffer:

## Architecture

```text
python3 /opt/zero2dash/scripts/test.py --output /tmp/test.png --no-framebuffer
Architecture
SPI TFT → /dev/fb1 → Python → Pi-hole API
```

---

## Notes

* Shows: Total, Blocked, % Blocked, Temp, Uptime
* No hardware backlight control
* Touch not used in UI

---

Private project.
Notes
Shows: Total, Blocked, % Blocked, Temp, Uptime
No hardware backlight control
Touch not used in UI
Private project.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
101 changes: 101 additions & 0 deletions scripts/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Placeholder display test: draw black screen with bold white TEST text."""

import argparse
import mmap
import struct
import sys
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

FBDEV_DEFAULT = "/dev/fb1"
W, H = 320, 240


def load_font(size: int, bold: bool = True) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf" if bold else "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
for font_path in candidates:
try:
return ImageFont.truetype(font_path, size=size)
except Exception:
continue
return ImageFont.load_default()


def make_test_frame() -> Image.Image:
image = Image.new("RGB", (W, H), (0, 0, 0))
draw = ImageDraw.Draw(image)
font = load_font(96, bold=True)

text = "TEST"
x0, y0, x1, y1 = draw.textbbox((0, 0), text, font=font)
text_w = x1 - x0
text_h = y1 - y0

x = (W - text_w) // 2
y = (H - text_h) // 2
draw.text((x, y), text, font=font, fill=(255, 255, 255))

return image


def rgb888_to_rgb565(image: Image.Image) -> bytes:
r, g, b = image.split()
r = r.point(lambda value: value >> 3)
g = g.point(lambda value: value >> 2)
b = b.point(lambda value: value >> 3)

rgb565 = bytearray()
rp, gp, bp = r.tobytes(), g.tobytes(), b.tobytes()
for i in range(len(rp)):
value = ((rp[i] & 0x1F) << 11) | ((gp[i] & 0x3F) << 5) | (bp[i] & 0x1F)
rgb565 += struct.pack("<H", value)
return bytes(rgb565)


def write_to_framebuffer(image: Image.Image, fbdev: str) -> None:
payload = rgb888_to_rgb565(image)
with open(fbdev, "r+b", buffering=0) as framebuffer:
mm = mmap.mmap(framebuffer.fileno(), W * H * 2, mmap.MAP_SHARED, mmap.PROT_WRITE)
mm.seek(0)
mm.write(payload)
mm.close()


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Display a TEST placeholder on the TFT framebuffer.")
parser.add_argument("--fbdev", default=FBDEV_DEFAULT, help=f"Framebuffer device path (default: {FBDEV_DEFAULT})")
parser.add_argument("--output", help="Optional output image path for local verification (PNG/JPG)")
parser.add_argument("--no-framebuffer", action="store_true", help="Skip framebuffer write (useful for local testing).")
return parser.parse_args()


def main() -> int:
args = parse_args()
frame = make_test_frame()

if args.output:
frame.save(args.output)
print(f"Saved preview image to {args.output}")

if args.no_framebuffer:
print("Skipping framebuffer write (--no-framebuffer set)")
return 0

fb_path = Path(args.fbdev)
if not fb_path.exists():
print(f"Framebuffer {args.fbdev} not found.", file=sys.stderr)
return 1

write_to_framebuffer(frame, args.fbdev)
print(f"Displayed TEST placeholder on {args.fbdev}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
12 changes: 12 additions & 0 deletions systemd/display.service
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
[Unit]
<<<<<<< HEAD:systemd/pihole-display.service
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove unresolved merge markers from service unit

This unit file still contains Git conflict markers, so systemd will reject it as invalid syntax and the day display service cannot start on systems that install this file. Any deployment that copies systemd/display.service and runs systemctl daemon-reload will hit a unit parse error until these markers are resolved.

Useful? React with 👍 / 👎.

Description=Pi-hole TFT Stats Display
=======
Description=Pi-hole TFT Day Display Rotator
>>>>>>> main:systemd/display.service
Comment on lines +2 to +6
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove merge conflict markers from display unit

This unit file still contains unresolved Git conflict markers (<<<<<<<, =======, >>>>>>>), so systemd cannot parse it as a valid service definition; the day display service will fail to load/start in deployments that copy this file.

Useful? React with 👍 / 👎.

After=network-online.target dev-fb1.device
Wants=network-online.target dev-fb1.device
ConditionPathExists=/dev/fb1

[Service]
Type=simple
<<<<<<< HEAD:systemd/pihole-display.service
WorkingDirectory=/opt/zero2dash
Environment=PYTHONUNBUFFERED=1

ExecStartPre=/opt/zero2dash/scripts/pihole-display-pre.sh
ExecStart=/usr/bin/python3 -u /opt/zero2dash/scripts/piholestats_v1.1.py
=======
WorkingDirectory=/home/pihole
Environment=PYTHONUNBUFFERED=1

ExecStartPre=/usr/local/bin/pihole-display-pre.sh
ExecStart=/usr/bin/python3 -u /home/pihole/display_rotator.py
>>>>>>> main:systemd/display.service

Restart=always
RestartSec=2
Expand Down
42 changes: 21 additions & 21 deletions systemd/pihole-display-dark.service
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
[Unit]
Description=Pi-hole TFT Stats Display DARK
After=network-online.target dev-fb1.device
Wants=network-online.target dev-fb1.device
ConditionPathExists=/dev/fb1
[Service]
Type=simple
WorkingDirectory=/home/pihole
Environment=PYTHONUNBUFFERED=1
ExecStartPre=/usr/local/bin/pihole-display-pre.sh
ExecStart=/usr/bin/python3 -u /home/pihole/piholestats_v1.2.py
Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
[Unit]
Description=Pi-hole TFT Stats Display DARK
After=network-online.target dev-fb1.device
Wants=network-online.target dev-fb1.device
ConditionPathExists=/dev/fb1

[Service]
Type=simple
WorkingDirectory=/opt/zero2dash
Environment=PYTHONUNBUFFERED=1

ExecStartPre=/opt/zero2dash/scripts/pihole-display-pre.sh
ExecStart=/usr/bin/python3 -u /opt/zero2dash/scripts/piholestats_v1.2.py

Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target