-
Notifications
You must be signed in to change notification settings - Fork 0
Touch controls added #4
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
795fd6a
027d132
da7cd40
24a4dab
388bc40
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 |
|---|---|---|
| @@ -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") | ||
|
Comment on lines
+163
to
+167
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 double-tap path still performs a page navigation because the first tap immediately enqueues Useful? React with 👍 / 👎.
Owner
Author
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. @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()) | ||
| 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 |
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.
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 unlessROTATOR_TOUCH_DEVICEis manually set. Selecting by capability (or at least validating touch events) would avoid this common misbinding.Useful? React with 👍 / 👎.
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.
@codex[agent] Please provide further explanation
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.
Absolutely — here’s the clarification for that review note on
display_rotator.pyline 127.Why the reviewer flagged line 127
At line 127, auto-selection is:
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,event0is often a keyboard, GPIO button input, or another non-touch device, so touch events (BTN_TOUCH,ABS_X, etc.) never arrive intouch_worker. That makes touch controls appear broken unlessROTATOR_TOUCH_DEVICEis manually configured. display_rotator.pyL122-L127 display_rotator.pyL155-L168Why this matters in practice
touch_worker()assumes the selected node emits:EV_ABSwithABS_X/ABS_MT_POSITION_X), andEV_KEYwithBTN_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-L127Commands 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 →