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
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

---
Expand Down
252 changes: 252 additions & 0 deletions display_rotator.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +126 to +127
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect a touch-capable input device before subscribing

Auto-selection currently binds to the first /dev/input/event* node, which is often a keyboard or other non-touch device on Raspberry Pi setups; when that happens, touch events are never seen and controls silently stop working unless ROTATOR_TOUCH_DEVICE is manually set. Selecting by capability (or at least validating touch events) would avoid this common misbinding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex[agent] Please provide further explanation

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Absolutely — here’s the clarification for that review note on display_rotator.py line 127.

Why the reviewer flagged line 127

At line 127, auto-selection is:

return candidates[0] if candidates else None

So the script takes the first /dev/input/event* device found, without checking whether it is actually a touchscreen. In many Raspberry Pi/Linux setups, event0 is often a keyboard, GPIO button input, or another non-touch device, so touch events (BTN_TOUCH, ABS_X, etc.) never arrive in touch_worker. That makes touch controls appear broken unless ROTATOR_TOUCH_DEVICE is manually configured. display_rotator.pyL122-L127 display_rotator.pyL155-L168

Why this matters in practice

touch_worker() assumes the selected node emits:

  • absolute X coordinates (EV_ABS with ABS_X/ABS_MT_POSITION_X), and
  • touch press/release (EV_KEY with BTN_TOUCH).

If the bound device does not expose those capabilities, none of the gesture logic will execute meaningfully. So the root issue is device capability mismatch, not gesture parsing itself. display_rotator.pyL155-L168

What “detect touch-capable input” would mean

A robust selector would iterate /dev/input/event* and pick one that advertises touch-relevant capabilities (e.g., BTN_TOUCH + absolute position axis), rather than first-in-list. The existing environment override can remain as a manual fallback. display_rotator.pyL123-L127


Commands I used

  • pwd && rg --files -g 'AGENTS.md'
  • nl -ba display_rotator.py | sed -n '100,190p'
  • python - <<'PY' ... urllib request to duckduckgo ... PY (attempted web lookup; blocked by proxy 403)
  • curl -I https://www.kernel.org/doc/html/latest/input/event-codes.html | head (attempted web lookup; blocked by proxy 403)

If you want, I can draft a concrete capability-based select_touch_device() patch next (minimal and dependency-free).

View task →



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")
Comment on lines +163 to +167
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Suppress page switch when handling a double tap

The double-tap path still performs a page navigation because the first tap immediately enqueues PREV/NEXT, and the second tap only adds TOGGLE_SCREEN; in practice, users trying to turn the display off/on with a double tap will also rotate to another page. This contradicts the documented gesture behavior and is reproducible whenever two tap-release events occur within DOUBLE_TAP_WINDOW_SECS.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

@aSbiEL0 aSbiEL0 Feb 28, 2026

Choose a reason for hiding this comment

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

@codex[agent] What if there was a 0.3s delay between a single tap and page change? script waits for the second tap and if it's within 0.3s it treats it as double tap, if after 0.3s it's single? Is this a good idea??

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())
42 changes: 21 additions & 21 deletions systemd/pihole-display.service → systemd/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 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