diff --git a/README.md b/README.md index 8b8537c..c86dc9a 100644 --- a/README.md +++ b/README.md @@ -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 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. \ No newline at end of file diff --git a/pihole-display-pre.sh b/scripts/pihole-display-pre.sh similarity index 100% rename from pihole-display-pre.sh rename to scripts/pihole-display-pre.sh diff --git a/piholestats_v1.0.py b/scripts/piholestats_v1.0.py similarity index 100% rename from piholestats_v1.0.py rename to scripts/piholestats_v1.0.py diff --git a/piholestats_v1.1.py b/scripts/piholestats_v1.1.py similarity index 100% rename from piholestats_v1.1.py rename to scripts/piholestats_v1.1.py diff --git a/piholestats_v1.2.py b/scripts/piholestats_v1.2.py similarity index 100% rename from piholestats_v1.2.py rename to scripts/piholestats_v1.2.py diff --git a/scripts/test.py b/scripts/test.py new file mode 100755 index 0000000..22e2b74 --- /dev/null +++ b/scripts/test.py @@ -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(" 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()) diff --git a/systemd/display.service b/systemd/display.service index 663782f..709c7a3 100644 --- a/systemd/display.service +++ b/systemd/display.service @@ -1,16 +1,28 @@ [Unit] +<<<<<<< HEAD:systemd/pihole-display.service +Description=Pi-hole TFT Stats Display +======= Description=Pi-hole TFT Day Display Rotator +>>>>>>> main:systemd/display.service 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 diff --git a/systemd/pihole-display-dark.service b/systemd/pihole-display-dark.service index 42bfff6..2c90f54 100644 --- a/systemd/pihole-display-dark.service +++ b/systemd/pihole-display-dark.service @@ -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