From 795fd6a2f824d96cc64929a3ec90822f1f8b448b Mon Sep 17 00:00:00 2001 From: aSbiEL0 <76663314+aSbiEL0@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:38:00 -0800 Subject: [PATCH 1/3] Add touch controls to display rotator --- README.md | 40 ++- display_rotator.py | 252 ++++++++++++++++++ ...pihole-display.service => display.service} | 42 +-- 3 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 display_rotator.py rename systemd/{pihole-display.service => display.service} (75%) diff --git a/README.md b/README.md index 13b4f70..8b8537c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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)](#) -Minimal framebuffer-based Pi-hole dashboard for Raspberry Pi (320×240 SPI TFT). +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 @@ -54,18 +54,50 @@ Edit in `piholestats_v1.2.py`: --- +## 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 pihole-display*.service /etc/systemd/system/ +sudo cp systemd/*.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now pihole-display.service +sudo systemctl enable --now display.service ``` Check logs: ```bash -journalctl -u pihole-display.service -n 50 --no-pager +journalctl -u display.service -n 50 --no-pager ``` --- diff --git a/display_rotator.py b/display_rotator.py new file mode 100644 index 0000000..8b27883 --- /dev/null +++ b/display_rotator.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Rotate multiple framebuffer dashboard scripts during day mode. + +Features: +- Timed page rotation across standalone scripts +- Touch controls: + - tap left side -> previous page + - tap right side -> next page + - double tap -> screen off/on +""" + +from __future__ import annotations + +import fcntl +import glob +import os +import queue +import select +import signal +import struct +import subprocess +import sys +import threading +import time +from pathlib import Path + + +DEFAULT_PAGES = ["piholestats_v1.0.py", "piholestats_v1.1.py"] +DEFAULT_ROTATE_SECS = 30 +SHUTDOWN_WAIT_SECS = 5 +DEFAULT_FBDEV = "/dev/fb1" +DEFAULT_WIDTH = 320 +DOUBLE_TAP_WINDOW_SECS = 0.35 + +# linux/input-event-codes.h +EV_SYN = 0x00 +EV_KEY = 0x01 +EV_ABS = 0x03 +ABS_X = 0x00 +ABS_MT_POSITION_X = 0x35 +BTN_TOUCH = 0x14A +INPUT_EVENT_STRUCT = struct.Struct("llHHI") + +# linux/fb.h +FBIOBLANK = 0x4611 +FB_BLANK_UNBLANK = 0 +FB_BLANK_POWERDOWN = 4 + + +class ScreenPower: + def __init__(self, fbdev: str) -> None: + self.fbdev = fbdev + self.screen_on = True + + def toggle(self) -> None: + target = FB_BLANK_POWERDOWN if self.screen_on else FB_BLANK_UNBLANK + try: + with open(self.fbdev, "rb", buffering=0) as fb: + fcntl.ioctl(fb.fileno(), FBIOBLANK, target) + self.screen_on = not self.screen_on + print(f"[rotator] Screen {'ON' if self.screen_on else 'OFF'}", flush=True) + except Exception as exc: + print(f"[rotator] Screen toggle failed on {self.fbdev}: {exc}", flush=True) + + +def parse_pages() -> list[str]: + raw = os.environ.get("ROTATOR_PAGES", "").strip() + if not raw: + return DEFAULT_PAGES.copy() + pages = [entry.strip() for entry in raw.split(",") if entry.strip()] + return pages or DEFAULT_PAGES.copy() + + +def parse_rotate_secs() -> int: + raw = os.environ.get("ROTATOR_SECS", str(DEFAULT_ROTATE_SECS)).strip() + try: + value = int(raw) + except ValueError: + value = DEFAULT_ROTATE_SECS + return max(5, value) + + +def parse_width() -> int: + raw = os.environ.get("ROTATOR_TOUCH_WIDTH", str(DEFAULT_WIDTH)).strip() + try: + value = int(raw) + except ValueError: + value = DEFAULT_WIDTH + return max(100, value) + + +def resolve_script(path_like: str, base_dir: Path) -> str | None: + path = Path(path_like) + if not path.is_absolute(): + path = base_dir / path + if not path.exists(): + print(f"[rotator] Skipping missing page: {path}", flush=True) + return None + return str(path) + + +def stop_child(child: subprocess.Popen[bytes] | None) -> None: + if child is None or child.poll() is not None: + return + + child.terminate() + try: + child.wait(timeout=SHUTDOWN_WAIT_SECS) + return + except subprocess.TimeoutExpired: + pass + + child.kill() + child.wait(timeout=SHUTDOWN_WAIT_SECS) + + +def launch_page(script_path: str) -> subprocess.Popen[bytes]: + print(f"[rotator] Launching {script_path}", flush=True) + return subprocess.Popen([sys.executable, "-u", script_path]) + + +def select_touch_device() -> str | None: + forced = os.environ.get("ROTATOR_TOUCH_DEVICE", "").strip() + if forced: + return forced if Path(forced).exists() else None + candidates = sorted(glob.glob("/dev/input/event*")) + return candidates[0] if candidates else None + + +def touch_worker(cmd_q: "queue.Queue[str]", stop_evt: threading.Event, touch_width: int) -> None: + device = select_touch_device() + if not device: + print("[rotator] No touch device found; touch controls disabled.", flush=True) + return + + print(f"[rotator] Touch controls listening on {device}", flush=True) + + last_x = touch_width // 2 + touch_down = False + last_tap_ts = 0.0 + + try: + with open(device, "rb", buffering=0) as fd: + while not stop_evt.is_set(): + readable, _, _ = select.select([fd], [], [], 0.2) + if not readable: + continue + + raw = fd.read(INPUT_EVENT_STRUCT.size) + if len(raw) != INPUT_EVENT_STRUCT.size: + continue + + _sec, _usec, ev_type, ev_code, ev_value = INPUT_EVENT_STRUCT.unpack(raw) + + if ev_type == EV_ABS and ev_code in (ABS_X, ABS_MT_POSITION_X): + last_x = ev_value + elif ev_type == EV_KEY and ev_code == BTN_TOUCH: + if ev_value == 1: + touch_down = True + elif ev_value == 0 and touch_down: + touch_down = False + now = time.monotonic() + if now - last_tap_ts <= DOUBLE_TAP_WINDOW_SECS: + cmd_q.put("TOGGLE_SCREEN") + last_tap_ts = 0.0 + else: + cmd_q.put("PREV" if last_x < (touch_width // 2) else "NEXT") + last_tap_ts = now + elif ev_type == EV_SYN: + continue + except Exception as exc: + print(f"[rotator] Touch worker stopped ({device}): {exc}", flush=True) + + +def main() -> int: + base_dir = Path(__file__).resolve().parent + rotate_secs = parse_rotate_secs() + touch_width = parse_width() + fbdev = os.environ.get("ROTATOR_FBDEV", DEFAULT_FBDEV) + + pages = [ + resolved + for resolved in (resolve_script(item, base_dir) for item in parse_pages()) + if resolved is not None + ] + + if not pages: + print("[rotator] No valid pages found; exiting.", file=sys.stderr, flush=True) + return 1 + + active_child: subprocess.Popen[bytes] | None = None + stop_requested = False + cmd_q: queue.Queue[str] = queue.Queue() + stop_evt = threading.Event() + screen = ScreenPower(fbdev) + + worker = threading.Thread(target=touch_worker, args=(cmd_q, stop_evt, touch_width), daemon=True) + worker.start() + + def request_stop(signum: int, _frame: object) -> None: + nonlocal stop_requested + stop_requested = True + print(f"[rotator] Received signal {signum}; stopping.", flush=True) + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + index = 0 + while not stop_requested: + script = pages[index] + active_child = launch_page(script) + + rotate_due = time.monotonic() + rotate_secs + next_index = (index + 1) % len(pages) + + while not stop_requested: + if active_child.poll() is not None: + print( + f"[rotator] Page exited early with code {active_child.returncode}: {script}", + flush=True, + ) + break + + if time.monotonic() >= rotate_due: + break + + try: + command = cmd_q.get(timeout=0.2) + except queue.Empty: + continue + + if command == "TOGGLE_SCREEN": + screen.toggle() + elif command == "NEXT": + next_index = (index + 1) % len(pages) + break + elif command == "PREV": + next_index = (index - 1) % len(pages) + break + + stop_child(active_child) + active_child = None + index = next_index + + stop_evt.set() + stop_child(active_child) + print("[rotator] Exit complete.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/systemd/pihole-display.service b/systemd/display.service similarity index 75% rename from systemd/pihole-display.service rename to systemd/display.service index 268baf8..663782f 100644 --- a/systemd/pihole-display.service +++ b/systemd/display.service @@ -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 Day Display Rotator +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/display_rotator.py + +Restart=always +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target From da7cd40de66214e953224ddfe18e82c7d30f470c Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:41:13 +0000 Subject: [PATCH 2/3] Initial plan From 388bc4082d13344ffe3b56fb2aa420bcc9bbf692 Mon Sep 17 00:00:00 2001 From: Codex <242516109+Codex@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:03:12 +0000 Subject: [PATCH 3/3] Initial plan (#6) Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>