Skip to content
Closed
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
54 changes: 50 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ Minimal framebuffer-based Pi-hole dashboard for Raspberry Pi (320×240 SPI TFT).

---

## Suggested project structure

```text
zero2dash/
├── scripts/
│ ├── pihole-display-pre.sh
│ ├── piholestats_v1.0.py
│ ├── piholestats_v1.1.py
│ ├── piholestats_v1.2.py
│ └── test_placeholder.py
├── systemd/
│ ├── pihole-display.service
│ ├── pihole-display-dark.service
│ ├── day.timer
│ └── night.timer
└── README.md
```

---

## Requirements

* Raspberry Pi OS (SPI enabled)
Expand Down Expand Up @@ -38,15 +58,25 @@ Reboot → display active on `/dev/fb1`.
### 2. Install Python dependency

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

---

### 3. Deploy project files

```bash
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_placeholder.py
```

---

## Configure

Edit in `piholestats_v1.2.py`:
Edit in `/opt/zero2dash/scripts/piholestats_v1.2.py`:

* `PIHOLE_HOST`
* `PIHOLE_PASSWORD`
Expand All @@ -57,7 +87,7 @@ Edit in `piholestats_v1.2.py`:
## Run via systemd

```bash
sudo cp pihole-display*.service /etc/systemd/system/
sudo cp /opt/zero2dash/systemd/pihole-display*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pihole-display.service
```
Expand All @@ -70,6 +100,22 @@ journalctl -u pihole-display.service -n 50 --no-pager

---

## Placeholder test script

To verify basic rendering logic:

```bash
python3 /opt/zero2dash/scripts/test_placeholder.py --fbdev /dev/fb1
```

Or generate a local preview without touching framebuffer:

```bash
python3 /opt/zero2dash/scripts/test_placeholder.py --output /tmp/test.png --no-framebuffer
```

---

## Architecture

```text
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
101 changes: 101 additions & 0 deletions scripts/test_placeholder.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())
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
42 changes: 21 additions & 21 deletions systemd/pihole-display.service
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
[Unit]
Description=Pi-hole TFT Stats Display
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.1.py
Restart=always
RestartSec=2
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
[Unit]
Description=Pi-hole TFT Stats Display
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.1.py

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

[Install]
WantedBy=multi-user.target