-
Notifications
You must be signed in to change notification settings - Fork 0
Project layout and deployment: move scripts to /opt/zero2dash, add placeholder test, update README and systemd paths #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,121 +1,66 @@ | ||
| # Pi-hole SPI TFT Dashboard | ||
|
|
||
| [](https://www.raspberrypi.com/) | ||
| [](#) | ||
|
|
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The install commands copy 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. | ||
| 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()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,28 @@ | ||
| [Unit] | ||
| <<<<<<< HEAD:systemd/pihole-display.service | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This unit file still contains Git conflict markers, so 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This unit file still contains unresolved Git conflict markers ( 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 | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README instructs users to enable
pihole-display.service, but this commit only providessystemd/display.service(pluspihole-display-dark.service), so following these commands will fail with a missing unit and leave day mode not installed.Useful? React with 👍 / 👎.