From 9265d66f89fa4b552674f2a08942983d686f6d84 Mon Sep 17 00:00:00 2001 From: Maciek Ostrowski Date: Sun, 8 Mar 2026 12:47:12 +0000 Subject: [PATCH 1/4] Clarify env vars for Google Pi-hole --- .env.example | 17 +++- README.md | 16 ++- scripts/calendash-api.py | 17 +++- scripts/photos-shuffle.py | 70 ++++++++++--- scripts/piholestats_v1.1.py | 190 +++++++++++++++++++++++++++++++++--- scripts/piholestats_v1.2.py | 17 ++++ 6 files changed, 288 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index ce61ce7..1467200 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # Never commit real secrets. # --- Google Calendar (calendash-api.py) --- -# Required secret credentials: set either dedicated Calendar creds OR shared Google creds. +# Required secret credentials: use a Desktop OAuth client, and set either dedicated Calendar creds OR shared Google creds. +# If the Google app is in testing, add your account as a consent-screen test user. # Required (if GOOGLE_CLIENT_ID is empty): GOOGLE_CALENDAR_CLIENT_ID= # Required secret (if GOOGLE_CLIENT_SECRET is empty): @@ -27,12 +28,20 @@ ICON_IMAGE=~/zero2dash/images/calendash-icon.png CALENDASH_FONT_PATH= # OAUTH_PORT default: 8080 OAUTH_PORT=8080 -# GOOGLE_TOKEN_PATH default: token.json -GOOGLE_TOKEN_PATH=~/zero2dash/token.json +# GOOGLE_TOKEN_PATH default: token.json (relative to the working directory; systemd uses /opt/zero2dash) +GOOGLE_TOKEN_PATH=token.json # --- Pi-hole dashboards (piholestats_v1.1.py / piholestats_v1.2.py) --- # Optional host, default: 127.0.0.1 PIHOLE_HOST=192.168.1.2 +# Required for remote hosts unless PIHOLE_HOST already includes http:// or https:// +PIHOLE_SCHEME=http +# Optional TLS verification: auto/true/false. For self-signed HTTPS, set false or use PIHOLE_CA_BUNDLE. +PIHOLE_VERIFY_TLS=auto +# Optional CA bundle for HTTPS certificate validation +PIHOLE_CA_BUNDLE= +# Optional request timeout seconds, default: 4 +PIHOLE_TIMEOUT=4 # Required secret: Pi-hole admin password for v6 API auth. PIHOLE_PASSWORD=replace-with-your-pihole-password # Optional: legacy API token for /admin/api.php fallback. @@ -49,6 +58,7 @@ ACTIVE_HOURS=22,7 # Required: Album ID to pull shuffled photos from. GOOGLE_PHOTOS_ALBUM_ID=replace-with-google-photos-album-id # OAuth credentials are required by one of these methods: +# Use a Desktop OAuth client. If the Google app is in testing, add your account as a consent-screen test user. # 1) Existing client secrets file (default path shown), OR # 2) GOOGLE_PHOTOS_CLIENT_ID + GOOGLE_PHOTOS_CLIENT_SECRET values. GOOGLE_PHOTOS_CLIENT_SECRETS_PATH=~/zero2dash/client_secret.json @@ -69,4 +79,5 @@ FALLBACK_IMAGE=~/zero2dash/images/photos-fallback.png # LOGO_PATH default: /images/goo-photos-icon.png LOGO_PATH=/images/goo-photos-icon.png # OAUTH_OPEN_BROWSER default: 0 (false) +# Loopback OAuth only: complete sign-in on the same machine, or use SSH port forwarding for headless Pi setup. OAUTH_OPEN_BROWSER=0 diff --git a/README.md b/README.md index 6d211de..aa507a2 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,24 @@ cp /opt/zero2dash/.env.example /opt/zero2dash/.env chmod 600 /opt/zero2dash/.env ``` -Set at minimum: +Set at minimum for Pi-hole: - `PIHOLE_HOST` -- `PIHOLE_PASSWORD` -- `PIHOLE_API_TOKEN` (optional fallback) +- `PIHOLE_SCHEME` if `PIHOLE_HOST` is remote and does not already include `http://` or `https://` +- `PIHOLE_PASSWORD` for v6 session auth, or `PIHOLE_API_TOKEN` for legacy token auth +- `PIHOLE_VERIFY_TLS=false` for self-signed HTTPS, or `PIHOLE_CA_BUNDLE=/path/to/ca.pem` to verify a private CA +- `PIHOLE_TIMEOUT` - `REFRESH_SECS` - `ACTIVE_HOURS` (inclusive `start,end` hour window in 24h format; cross-midnight values like `22,7` are supported) -## Run via systemd +Google OAuth notes: + +- Use Desktop OAuth clients for Calendar and Photos. +- Loopback OAuth only: complete sign-in on the same machine as the script, or tunnel the callback port from a headless Pi with `ssh -L 8080:localhost:8080 pihole@pihole`. +- If the Google consent screen is in testing, add your account as a test user. +- `calendash-api.py` defaults `GOOGLE_TOKEN_PATH` to `token.json` relative to `/opt/zero2dash` under systemd; `photos-shuffle.py` must keep using a separate `GOOGLE_TOKEN_PATH_PHOTOS`. +## Run via systemd Install and enable canonical units: ```sh diff --git a/scripts/calendash-api.py b/scripts/calendash-api.py index 4926f42..2032fb3 100755 --- a/scripts/calendash-api.py +++ b/scripts/calendash-api.py @@ -45,7 +45,7 @@ def _normalize_scopes(raw_scopes: Any) -> set[str]: if isinstance(raw_scopes, str): - return {raw_scopes} + return {scope for scope in raw_scopes.replace(",", " ").split() if scope} if isinstance(raw_scopes, (list, tuple, set)): return {str(scope) for scope in raw_scopes if scope} return set() @@ -258,6 +258,16 @@ def build_client_config(client_id: str, client_secret: str, oauth_port: int) -> } +def loopback_oauth_guidance(oauth_port: int) -> list[str]: + redirect_uri = expected_redirect_uri(oauth_port) + return [ + "Loopback OAuth only: complete Google sign-in on the same machine that is running this script.", + f"For a headless Pi, forward the callback port first: ssh -L {oauth_port}:localhost:{oauth_port} @", + "Use a Desktop OAuth client. If your Google app is in testing, add your account as a test user.", + f"Expected redirect URI: {redirect_uri}", + ] + + def save_credentials(creds: Credentials, token_path: Path) -> None: token_path.write_text(creds.to_json(), encoding="utf-8") os.chmod(token_path, 0o600) @@ -316,8 +326,9 @@ def get_credentials(client_id: str, client_secret: str, token_path: Path, oauth_ "Google blocked this OAuth client. For calendar, use a Desktop OAuth client and add your account as a test user on the consent screen." ) logging.error("You can also set GOOGLE_CALENDAR_CLIENT_ID / GOOGLE_CALENDAR_CLIENT_SECRET to use a dedicated calendar OAuth client.") - logging.info("Local server auth failed (%s); falling back to console flow.", exc) - creds = flow.run_console() + for message in loopback_oauth_guidance(oauth_port): + logging.error(message) + raise RuntimeError("Loopback OAuth setup failed.") from exc save_credentials(creds, token_path) return creds diff --git a/scripts/photos-shuffle.py b/scripts/photos-shuffle.py index 79d2af0..747b1aa 100755 --- a/scripts/photos-shuffle.py +++ b/scripts/photos-shuffle.py @@ -22,8 +22,10 @@ GOOGLE_TOKEN_PATH_PHOTOS if needed). - On first run, if token is missing/invalid and refresh is unavailable, the script starts a local OAuth flow and prints instructions to complete login. -- In headless environments, it does not auto-launch a browser by default; - copy the printed URL into any browser and paste back the result. +- Loopback OAuth only: complete the browser sign-in on the same machine as the + script, or use SSH port forwarding to forward the callback port from the Pi. +- Use a Desktop OAuth client. If the Google app is in testing, add your + account as a test user before first run. Fallback: - Ensure local fallback image exists at ~/zero2dash/images/photos-fallback.png @@ -77,7 +79,7 @@ class Config: def _normalize_scopes(raw_scopes: Any) -> set[str]: if isinstance(raw_scopes, str): - return {raw_scopes} + return {scope for scope in raw_scopes.replace(",", " ").split() if scope} if isinstance(raw_scopes, (list, tuple, set)): return {str(scope) for scope in raw_scopes if scope} return set() @@ -102,6 +104,42 @@ def debug(self, message: str) -> None: print(f"[debug] {message}") +def expected_redirect_uri(oauth_port: int) -> str: + return f"http://localhost:{oauth_port}/" + + +def build_client_config(client_id: str, client_secret: str, oauth_port: int) -> dict[str, Any]: + redirect_uri = expected_redirect_uri(oauth_port) + return { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": [ + "http://localhost", + "http://localhost/", + f"http://localhost:{oauth_port}", + redirect_uri, + "http://127.0.0.1", + "http://127.0.0.1/", + f"http://127.0.0.1:{oauth_port}", + redirect_uri.replace("localhost", "127.0.0.1", 1), + ], + } + } + + +def loopback_oauth_guidance(oauth_port: int) -> list[str]: + redirect_uri = expected_redirect_uri(oauth_port) + return [ + "Loopback OAuth only: complete Google sign-in on the same machine that is running this script.", + f"For a headless Pi, forward the callback port first: ssh -L {oauth_port}:localhost:{oauth_port} @", + "Use a Desktop OAuth client. If your Google app is in testing, add your account as a test user.", + f"Expected redirect URI: {redirect_uri}", + ] + + def _as_int(name: str, value: str) -> int: try: return int(value) @@ -200,7 +238,7 @@ def authenticate(config: Config, log: Log) -> Credentials: calendar_default_token = (DEFAULT_ROOT / "token.json").resolve() if config.token_path.resolve() == calendar_default_token: raise ValueError( - "GOOGLE_TOKEN_PATH points to token.json, which is reserved for calendash-api.py. " + "GOOGLE_TOKEN_PATH_PHOTOS points to token.json, which is reserved for calendash-api.py. " "Use a separate photos token path (default: ~/zero2dash/token_photos.json)." ) @@ -236,20 +274,13 @@ def authenticate(config: Config, log: Log) -> Credentials: if not creds or not creds.valid: log.info("No valid Google token found; starting OAuth local server flow.") log.info( - "Follow the browser prompt to authorize Google Photos access, then return to this terminal." + "Complete Google sign-in on this machine, or use SSH port forwarding for the loopback callback." ) if config.client_secrets_path.exists(): flow = InstalledAppFlow.from_client_secrets_file(str(config.client_secrets_path), SCOPES) elif config.client_id and config.client_secret: flow = InstalledAppFlow.from_client_config( - { - "installed": { - "client_id": config.client_id, - "client_secret": config.client_secret, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - } - }, + build_client_config(config.client_id, config.client_secret, config.oauth_port), SCOPES, ) else: @@ -263,13 +294,18 @@ def authenticate(config: Config, log: Log) -> Credentials: prompt="consent", authorization_prompt_message="Open this URL in your browser to authorize Google Photos access: {url}", open_browser=config.oauth_open_browser, + redirect_uri_trailing_slash=True, ) except Exception as exc: exc_text = str(exc).lower() + if "redirect_uri_mismatch" in exc_text: + log.info(f"OAuth redirect mismatch. Expected redirect URI: {expected_redirect_uri(config.oauth_port)}") if any(tag in exc_text for tag in ["access blocked", "app is blocked", "app restricted", "invalid_client"]): log.info("Google blocked this OAuth client for Photos. Use a dedicated Desktop OAuth client and add your account as a test user.") log.info("Set GOOGLE_PHOTOS_CLIENT_ID / GOOGLE_PHOTOS_CLIENT_SECRET (or GOOGLE_PHOTOS_CLIENT_SECRETS_PATH) in .env.") - raise + for message in loopback_oauth_guidance(config.oauth_port): + log.info(message) + raise RuntimeError("Loopback OAuth setup failed") from exc config.token_path.parent.mkdir(parents=True, exist_ok=True) config.token_path.write_text(creds.to_json(), encoding="utf-8") @@ -518,6 +554,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--test", action="store_true", help="Render to /tmp/photos-shuffle-test.png instead of framebuffer") parser.add_argument("--debug", action="store_true", help="Enable verbose debug logs") parser.add_argument("--check-config", action="store_true", help="Validate env configuration and exit") + parser.add_argument("--auth-only", action="store_true", help="Run OAuth/token setup only and exit") parser.add_argument("--smoke-list-fetch", action="store_true", help="Run paginated list fetch smoke check and exit") return parser.parse_args() @@ -545,6 +582,11 @@ def main() -> int: print("[photos-shuffle.py] Configuration check passed.") return 0 + if args.auth_only: + authenticate(config, log) + print("[photos-shuffle.py] Authentication check passed.") + return 0 + source_image: Path | None = None try: source_image = choose_online_image(config, log) diff --git a/scripts/piholestats_v1.1.py b/scripts/piholestats_v1.1.py index 014ecf3..2f225a3 100644 --- a/scripts/piholestats_v1.1.py +++ b/scripts/piholestats_v1.1.py @@ -4,7 +4,7 @@ # Version 1.1 - Introducing dark mode # LEGACY: kept for compatibility/manual use; canonical night service uses piholestats_v1.2.py -import os, sys, time, json, urllib.request, urllib.parse, mmap, struct, argparse, ssl, errno +import os, sys, time, json, urllib.request, urllib.parse, urllib.error, mmap, struct, argparse, ssl, errno from pathlib import Path from datetime import datetime from PIL import Image, ImageDraw, ImageFont @@ -23,6 +23,7 @@ PIHOLE_CA_BUNDLE = "" PIHOLE_PASSWORD = "" PIHOLE_API_TOKEN = "" +PIHOLE_AUTH_MODE = "" TITLE = "Pi-hole" # Colours COL_BG = (0, 0, 0) @@ -84,6 +85,19 @@ def _parse_verify_tls(value: str) -> str: return normalized +def _host_requires_explicit_scheme(raw_host: str) -> bool: + candidate = raw_host.strip() + if not candidate: + return False + parsed = urllib.parse.urlsplit(candidate if "://" in candidate else f"//{candidate}") + if parsed.scheme: + return False + hostname = (parsed.hostname or candidate.split("/")[0].split(":")[0]).strip("[]").lower() + if not hostname: + return False + return hostname not in {"localhost", "::1"} and not hostname.startswith("127.") + + def validate_hour_bounds(hour: int) -> None: if hour < 0 or hour > 23: raise ValueError(f"hour must be in range 0-23, got {hour}") @@ -138,7 +152,7 @@ def record(name: str, *, default=None, required=False, validator=None): scheme = record("PIHOLE_SCHEME", default="", validator=_parse_scheme) verify_tls = record("PIHOLE_VERIFY_TLS", default="auto", validator=_parse_verify_tls) ca_bundle = record("PIHOLE_CA_BUNDLE", default="") - password = record("PIHOLE_PASSWORD", required=True) + password = record("PIHOLE_PASSWORD", default="") api_token = record("PIHOLE_API_TOKEN", default="") refresh_secs = record("REFRESH_SECS", default=REFRESH_SECS, validator=_parse_int) request_timeout = record("PIHOLE_TIMEOUT", default=REQUEST_TIMEOUT, validator=_parse_float) @@ -150,6 +164,16 @@ def record(name: str, *, default=None, required=False, validator=None): errors.append("PIHOLE_TIMEOUT is invalid: must be greater than 0") if ca_bundle and not Path(str(ca_bundle)).is_file(): errors.append("PIHOLE_CA_BUNDLE is invalid: file does not exist") + if _host_requires_explicit_scheme(str(host)) and not str(scheme): + errors.append( + "Remote PIHOLE_HOST requires an explicit scheme: set PIHOLE_SCHEME=http|https or include http:// / https:// in PIHOLE_HOST" + ) + + auth_mode = _detect_auth_mode(str(password), str(api_token)) + if auth_mode is None: + errors.append( + "Auth configuration is invalid: set PIHOLE_PASSWORD for v6 session auth or PIHOLE_API_TOKEN for legacy token auth" + ) if errors: return None, errors @@ -162,6 +186,7 @@ def record(name: str, *, default=None, required=False, validator=None): "pihole_ca_bundle": str(ca_bundle), "pihole_password": str(password), "pihole_api_token": str(api_token), + "pihole_auth_mode": auth_mode, "request_timeout": float(request_timeout), "refresh_secs": int(refresh_secs), "active_hours": active_hours if isinstance(active_hours, tuple) else ACTIVE_HOURS, @@ -178,7 +203,7 @@ def parse_args(): def apply_config(config: dict[str, object], output_image: str = "") -> None: global FBDEV, PIHOLE_HOST, PIHOLE_SCHEME, PIHOLE_VERIFY_TLS, PIHOLE_CA_BUNDLE - global PIHOLE_PASSWORD, PIHOLE_API_TOKEN, REFRESH_SECS, ACTIVE_HOURS, BASE_URL + global PIHOLE_PASSWORD, PIHOLE_API_TOKEN, PIHOLE_AUTH_MODE, REFRESH_SECS, ACTIVE_HOURS, BASE_URL global REQUEST_TIMEOUT, REQUEST_TLS_VERIFY, OUTPUT_IMAGE FBDEV = str(config["fbdev"]) PIHOLE_HOST = str(config["pihole_host"]) @@ -187,6 +212,7 @@ def apply_config(config: dict[str, object], output_image: str = "") -> None: PIHOLE_CA_BUNDLE = str(config["pihole_ca_bundle"]) PIHOLE_PASSWORD = str(config["pihole_password"]) PIHOLE_API_TOKEN = str(config["pihole_api_token"]) + PIHOLE_AUTH_MODE = str(config["pihole_auth_mode"]) REQUEST_TIMEOUT = float(config["request_timeout"]) REFRESH_SECS = int(config["refresh_secs"]) ACTIVE_HOURS = config["active_hours"] @@ -365,25 +391,54 @@ def _normalize_host(raw_host: str, preferred_scheme: str = "") -> str: BASE_URL = _normalize_host(PIHOLE_HOST, preferred_scheme=PIHOLE_SCHEME) + +def _detect_auth_mode(password: str, api_token: str) -> str | None: + if password: + return "v6-session" + if api_token: + return "legacy-token" + return None + + +def _auth_failure(msg: str) -> RuntimeError: + return RuntimeError(f"AUTH_FAILURE: {msg}") + + +def _transport_failure(msg: str) -> RuntimeError: + return RuntimeError(f"TRANSPORT_FAILURE: {msg}") + + def _auth_get_sid(): global _SID, _SID_EXP if not PIHOLE_PASSWORD: - raise RuntimeError("Missing PIHOLE_PASSWORD") - js = _http_json(f"{BASE_URL}/api/auth", method="POST", - body={"password": PIHOLE_PASSWORD}, timeout=4) + raise _auth_failure("PIHOLE_PASSWORD is not configured") + try: + js = _http_json(f"{BASE_URL}/api/auth", method="POST", + body={"password": PIHOLE_PASSWORD}, timeout=4) + except urllib.error.HTTPError as exc: + if exc.code in {401, 403}: + raise _auth_failure("v6 session login rejected (check PIHOLE_PASSWORD)") from exc + raise _transport_failure(f"v6 auth HTTP error {exc.code}") from exc + except urllib.error.URLError as exc: + raise _transport_failure(f"v6 auth transport error: {exc.reason}") from exc sess = js.get("session", {}) if not sess.get("valid", False): - raise RuntimeError("Auth failed") + raise _auth_failure("v6 session response invalid (check PIHOLE_PASSWORD)") _SID = sess["sid"] _SID_EXP = time.time() + int(sess.get("validity", 1800)) - 10 return _SID + def _ensure_sid(): if _SID and time.time() < _SID_EXP: return _SID return _auth_get_sid() + def fetch_pihole(): + if PIHOLE_AUTH_MODE == "legacy-token": + return _fetch_legacy_summary() + try: sid = _ensure_sid() url = f"{BASE_URL}/api/stats/summary?sid=" + urllib.parse.quote(sid, safe="") @@ -400,25 +455,130 @@ def fetch_pihole(): "total": total, "blocked": blocked, "percent": percent, - "ok": True + "ok": True, + "status": "OK", } - except Exception: - pass + except Exception as exc: + v6_error = exc + primary_summary = _exception_summary(v6_error, "V6") + if not PIHOLE_API_TOKEN: + return { + "total": 0, + "blocked": 0, + "percent": 0.0, + "ok": False, + "status": _status_from_exception(v6_error, "AUTH ONLY"), + "failure": _failure_from_exception(v6_error, source="v6"), + } + + legacy = _fetch_legacy_summary() + if legacy["ok"]: + legacy["status"] = "LEGACY" + return legacy + + fallback_summary = legacy.get("failure", {}).get("summary") or legacy.get("status", "LEGACY FAIL") + print( + f"[piholestats_v1.1.py] Summary fetch failed. primary={primary_summary}; fallback={fallback_summary}", + file=sys.stderr, + ) + return { + "total": 0, + "blocked": 0, + "percent": 0.0, + "ok": False, + "status": f"{_status_from_exception(v6_error, 'V6')} / {legacy.get('status', 'LEGACY FAIL')}", + "failure": { + "reason": _failure_reason_from_exception(v6_error), + "summary": f"primary={primary_summary}; fallback={fallback_summary}", + "source": "v6+legacy", + "primary": primary_summary, + "fallback": fallback_summary, + }, + } + +def _fetch_legacy_summary(): try: - params = {"summaryRaw": ""} - if PIHOLE_API_TOKEN: - params["auth"] = PIHOLE_API_TOKEN + params = {"summaryRaw": "", "auth": PIHOLE_API_TOKEN} query = urllib.parse.urlencode(params) legacy = _http_json(f"{BASE_URL}/admin/api.php?{query}", timeout=4) + if str(legacy.get("status", "")).lower() == "unauthorized": + raise _auth_failure("legacy token rejected (check PIHOLE_API_TOKEN)") return { "total": int(legacy.get("dns_queries_today", 0)), "blocked": int(legacy.get("ads_blocked_today", 0)), "percent": float(legacy.get("ads_percentage_today", 0.0)), "ok": True, + "status": "OK", } - except Exception: - return {"total":0,"blocked":0,"percent":0.0,"ok":False} + except urllib.error.HTTPError as exc: + if exc.code in {401, 403}: + failure_exc = _auth_failure("legacy token rejected (check PIHOLE_API_TOKEN)") + message = _status_from_exception(failure_exc, "LEGACY") + else: + failure_exc = _transport_failure(f"legacy HTTP error {exc.code}") + message = _status_from_exception(failure_exc, "LEGACY") + return { + "total":0, + "blocked":0, + "percent":0.0, + "ok":False, + "status": message, + "failure": _failure_from_exception(failure_exc, source="legacy"), + } + except urllib.error.URLError as exc: + failure_exc = _transport_failure(f"legacy transport error: {exc.reason}") + message = _status_from_exception(failure_exc, "LEGACY") + return { + "total":0, + "blocked":0, + "percent":0.0, + "ok":False, + "status": message, + "failure": _failure_from_exception(failure_exc, source="legacy"), + } + except Exception as exc: + return { + "total":0, + "blocked":0, + "percent":0.0, + "ok":False, + "status": _status_from_exception(exc, "LEGACY"), + "failure": _failure_from_exception(exc, source="legacy"), + } + + +def _status_from_exception(exc: Exception, label: str) -> str: + message = str(exc) + if message.startswith("AUTH_FAILURE:"): + return f"{label} AUTH FAIL" + if message.startswith("TRANSPORT_FAILURE:"): + return f"{label} NET FAIL" + return f"{label} ERROR" + + +def _failure_reason_from_exception(exc: Exception) -> str: + message = str(exc) + if message.startswith("AUTH_FAILURE:"): + return "auth_failed" + if message.startswith("TRANSPORT_FAILURE:"): + lowered = message.lower() + if "timed out" in lowered or "timeout" in lowered: + return "network_timeout" + return "network_error" + return "unknown_error" + + +def _exception_summary(exc: Exception, label: str) -> str: + return f"{label} {_status_from_exception(exc, '').strip()} ({exc})" + + +def _failure_from_exception(exc: Exception, source: str) -> dict[str, str]: + return { + "reason": _failure_reason_from_exception(exc), + "summary": _exception_summary(exc, source.upper()), + "source": source, + } # ---------- rendering ---------- def draw_degree_circle(d, x, y, r, colour): diff --git a/scripts/piholestats_v1.2.py b/scripts/piholestats_v1.2.py index 004634a..d0a227c 100644 --- a/scripts/piholestats_v1.2.py +++ b/scripts/piholestats_v1.2.py @@ -82,6 +82,19 @@ def _parse_verify_tls(value: str) -> str: return normalized +def _host_requires_explicit_scheme(raw_host: str) -> bool: + candidate = raw_host.strip() + if not candidate: + return False + parsed = urllib.parse.urlsplit(candidate if "://" in candidate else f"//{candidate}") + if parsed.scheme: + return False + hostname = (parsed.hostname or candidate.split("/")[0].split(":")[0]).strip("[]").lower() + if not hostname: + return False + return hostname not in {"localhost", "::1"} and not hostname.startswith("127.") + + def validate_hour_bounds(hour: int) -> None: if hour < 0 or hour > 23: raise ValueError(f"hour must be in range 0-23, got {hour}") @@ -148,6 +161,10 @@ def record(name: str, *, default=None, required=False, validator=None): errors.append("PIHOLE_TIMEOUT is invalid: must be greater than 0") if ca_bundle and not Path(str(ca_bundle)).is_file(): errors.append("PIHOLE_CA_BUNDLE is invalid: file does not exist") + if _host_requires_explicit_scheme(str(host)) and not str(scheme): + errors.append( + "Remote PIHOLE_HOST requires an explicit scheme: set PIHOLE_SCHEME=http|https or include http:// / https:// in PIHOLE_HOST" + ) auth_mode = _detect_auth_mode(str(password), str(api_token)) if auth_mode is None: From dd1572222a3b348073bd09166c1c2ac19a56eabb Mon Sep 17 00:00:00 2001 From: Maciek Ostrowski Date: Sun, 8 Mar 2026 14:05:28 +0000 Subject: [PATCH 2/4] Update OAuth docs and exclude _confg --- .env.example | 3 ++ README.md | 2 + display_rotator.py | 3 +- pr-body.md | 9 +++++ requirements.txt | 10 +++++ scripts/calendash-img.py | 4 +- scripts/photos-shuffle.py | 76 ++++++++++++++++++++++++++++++++++--- scripts/piholestats_v1.1.py | 6 +++ scripts/piholestats_v1.2.py | 6 +++ 9 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 pr-body.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example index 1467200..a62f4bb 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,7 @@ ACTIVE_HOURS=22,7 GOOGLE_PHOTOS_ALBUM_ID=replace-with-google-photos-album-id # OAuth credentials are required by one of these methods: # Use a Desktop OAuth client. If the Google app is in testing, add your account as a consent-screen test user. +# Enable Photos Library API in the same Google Cloud project as this OAuth client. # 1) Existing client secrets file (default path shown), OR # 2) GOOGLE_PHOTOS_CLIENT_ID + GOOGLE_PHOTOS_CLIENT_SECRET values. GOOGLE_PHOTOS_CLIENT_SECRETS_PATH=~/zero2dash/client_secret.json @@ -81,3 +82,5 @@ LOGO_PATH=/images/goo-photos-icon.png # OAUTH_OPEN_BROWSER default: 0 (false) # Loopback OAuth only: complete sign-in on the same machine, or use SSH port forwarding for headless Pi setup. OAUTH_OPEN_BROWSER=0 + + diff --git a/README.md b/README.md index aa507a2..7c729de 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Google OAuth notes: - Use Desktop OAuth clients for Calendar and Photos. - Loopback OAuth only: complete sign-in on the same machine as the script, or tunnel the callback port from a headless Pi with `ssh -L 8080:localhost:8080 pihole@pihole`. - If the Google consent screen is in testing, add your account as a test user. +- Enable Google Photos Library API in the same Google Cloud project as the Photos OAuth client. - `calendash-api.py` defaults `GOOGLE_TOKEN_PATH` to `token.json` relative to `/opt/zero2dash` under systemd; `photos-shuffle.py` must keep using a separate `GOOGLE_TOKEN_PATH_PHOTOS`. ## Run via systemd @@ -129,3 +130,4 @@ journalctl -u pihole-display-dark.service -n 50 --no-pager - `display_rotator.py` excludes `piholestats_v1.2.py` by default so day mode and night mode stay distinct. - Static image scripts (for example `tram-info.py`, `weather-dash.py`, `calendash-img.py`) are rotator-friendly page scripts, not systemd service units by themselves. + diff --git a/display_rotator.py b/display_rotator.py index d9c5ba7..dfd76be 100644 --- a/display_rotator.py +++ b/display_rotator.py @@ -28,7 +28,7 @@ DEFAULT_PAGES_DIR = "scripts" DEFAULT_PAGE_GLOB = "*.py" -DEFAULT_EXCLUDE_PATTERNS = ["piholestats_v1.2.py", "calendash-api.py"] +DEFAULT_EXCLUDE_PATTERNS = ["piholestats_v1.2.py", "calendash-api.py", "_config.py"] DEFAULT_ROTATE_SECS = 30 SHUTDOWN_WAIT_SECS = 5 DEFAULT_FBDEV = "/dev/fb1" @@ -516,3 +516,4 @@ def request_stop(signum: int, _frame: object) -> None: if __name__ == "__main__": raise SystemExit(main()) + diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 0000000..58ac5da --- /dev/null +++ b/pr-body.md @@ -0,0 +1,9 @@ +## Summary +- require an explicit scheme for remote Pi-hole hosts and validate TLS/timeout-related configuration +- improve Pi-hole auth handling in `piholestats_v1.1.py`, including v6-session vs legacy-token mode detection and clearer auth/transport failure reporting +- tighten Google Calendar and Photos OAuth handling around loopback redirects, Desktop OAuth clients, and token scope parsing +- add a Photos `--auth-only` mode and improve token-path separation from the calendar flow +- update `.env.example` and `README.md` to document the revised Pi-hole and Google OAuth setup + +## Testing +- not run from this Codex environment diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed596c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Runtime Python dependencies for zero2dash scripts. +# System tools such as git, gh, systemd, and framebuffer drivers are not pip packages. + +Pillow>=10.0 +python-dotenv>=1.0 +pytz>=2024.1 +google-auth>=2.0 +google-auth-oauthlib>=1.2 +google-auth-httplib2>=0.2 +google-api-python-client>=2.0 diff --git a/scripts/calendash-img.py b/scripts/calendash-img.py index 62bd73d..90f2da6 100755 --- a/scripts/calendash-img.py +++ b/scripts/calendash-img.py @@ -16,7 +16,7 @@ FBDEV_DEFAULT = "/dev/fb1" TOUCH_DEVICE_DEFAULT = "/dev/input/event0" W, H = 320, 240 -DEFAULT_IMAGE = Path(__file__).resolve().parent.parent / "images" / "calendash" / "output.png" +DEFAULT_IMAGE = Path(__file__).resolve().parent.parent / "images" / "calendash.png" INPUT_EVENT_STRUCT = struct.Struct("llHHI") EV_KEY = 0x01 BTN_TOUCH = 0x14A @@ -38,7 +38,7 @@ def rgb888_to_rgb565(image: Image.Image) -> bytes: def load_frame(image_path: Path) -> Image.Image: if not image_path.exists(): - raise FileNotFoundError(f"Background image not found: {image_path}") + raise FileNotFoundError(f"Calendar image not found: {image_path}") return Image.open(image_path).convert("RGB").resize((W, H), Image.Resampling.LANCZOS) diff --git a/scripts/photos-shuffle.py b/scripts/photos-shuffle.py index 747b1aa..be8691f 100755 --- a/scripts/photos-shuffle.py +++ b/scripts/photos-shuffle.py @@ -42,6 +42,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from urllib.error import HTTPError from _config import get_env, report_validation_errors @@ -49,7 +50,6 @@ from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow -from googleapiclient.discovery import build from PIL import Image, ImageEnhance SCOPES = ["https://www.googleapis.com/auth/photoslibrary.readonly"] @@ -313,12 +313,78 @@ def authenticate(config: Config, log: Log) -> Credentials: return creds +def _photos_api_json(creds: Credentials, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: + from urllib.request import Request as UrlRequest, urlopen + + url = f"https://photoslibrary.googleapis.com/v1/{endpoint}" + payload = json.dumps(body).encode("utf-8") + request = UrlRequest( + url, + data=payload, + headers={ + "Authorization": f"Bearer {creds.token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=20) as response: # nosec B310 + data = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = _describe_google_api_error(exc) + if detail: + raise RuntimeError(detail) from exc + raise + if not isinstance(data, dict): + raise ValueError("Google Photos API response must be a JSON object") + return data + + +def _describe_google_api_error(exc: HTTPError) -> str: + try: + raw_body = exc.read() + except Exception: + raw_body = b"" + + body_text = raw_body.decode("utf-8", "replace").strip() if raw_body else "" + try: + payload = json.loads(body_text) if body_text else {} + except json.JSONDecodeError: + payload = {} + + error = payload.get("error") if isinstance(payload, dict) else None + if not isinstance(error, dict): + return f"Google Photos API request failed ({exc.code} {exc.reason})" + + message = str(error.get("message") or f"Google Photos API request failed ({exc.code} {exc.reason})").strip() + details = error.get("details") + if not isinstance(details, list): + return message + + for detail in details: + if not isinstance(detail, dict): + continue + metadata = detail.get("metadata") + if detail.get("reason") == "SERVICE_DISABLED" and isinstance(metadata, dict): + activation_url = metadata.get("activationUrl") + consumer = metadata.get("consumer") + if activation_url and consumer: + return f"{message} Enable Photos Library API for {consumer}: {activation_url}" + if activation_url: + return f"{message} Enable Photos Library API here: {activation_url}" + + return message + + def list_album_images(creds: Credentials, album_id: str, log: Log) -> list[dict[str, Any]]: - service = build("photoslibrary", "v1", credentials=creds, cache_discovery=False) - return _list_album_images_from_service(service, album_id, log) + return _list_album_images(lambda body: _photos_api_json(creds, "mediaItems:search", body), album_id, log) def _list_album_images_from_service(service: Any, album_id: str, log: Log) -> list[dict[str, Any]]: + return _list_album_images(lambda body: service.mediaItems().search(body=body).execute(), album_id, log) + + +def _list_album_images(fetch_page: Any, album_id: str, log: Log) -> list[dict[str, Any]]: if not album_id: raise ValueError("album_id is required") @@ -332,7 +398,7 @@ def _list_album_images_from_service(service: Any, album_id: str, log: Log) -> li if page_token: body["pageToken"] = page_token - response = service.mediaItems().search(body=body).execute() + response = fetch_page(body) if not isinstance(response, dict): raise ValueError("Google Photos API response must be a JSON object") @@ -358,7 +424,6 @@ def _list_album_images_from_service(service: Any, album_id: str, log: Log) -> li log.debug(f"Album returned {len(images)} image media items across {page_count} pages") return images - def smoke_check_list_fetch(log: Log) -> None: class _Request: def __init__(self, response: dict[str, Any]): @@ -631,3 +696,4 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) + diff --git a/scripts/piholestats_v1.1.py b/scripts/piholestats_v1.1.py index 2f225a3..389d108 100644 --- a/scripts/piholestats_v1.1.py +++ b/scripts/piholestats_v1.1.py @@ -8,9 +8,12 @@ from pathlib import Path from datetime import datetime from PIL import Image, ImageDraw, ImageFont +from dotenv import load_dotenv from _config import get_env, report_validation_errors +DEFAULT_ROOT = Path('~/zero2dash').expanduser() + # -------- CONFIG -------- FBDEV = "/dev/fb1" W, H = 320, 240 @@ -663,6 +666,7 @@ def main(): print(f"[piholestats_v1.1.py] Self checks passed.") return 0 + load_dotenv(DEFAULT_ROOT / '.env') config, errors = validate_config() if errors: report_validation_errors("piholestats_v1.1.py", errors) @@ -710,3 +714,5 @@ def main(): raise SystemExit(main()) except KeyboardInterrupt: pass + + diff --git a/scripts/piholestats_v1.2.py b/scripts/piholestats_v1.2.py index d0a227c..a91276d 100644 --- a/scripts/piholestats_v1.2.py +++ b/scripts/piholestats_v1.2.py @@ -7,9 +7,12 @@ from pathlib import Path from datetime import datetime from PIL import Image, ImageDraw, ImageFont +from dotenv import load_dotenv from _config import get_env, report_validation_errors +DEFAULT_ROOT = Path('~/zero2dash').expanduser() + # -------- CONFIG -------- FBDEV = "/dev/fb1" W, H = 320, 240 @@ -646,6 +649,7 @@ def main(): print(f"[piholestats_v1.2.py] Self checks passed.") return 0 + load_dotenv(DEFAULT_ROOT / '.env') config, errors = validate_config() if errors: report_validation_errors("piholestats_v1.2.py", errors) @@ -700,3 +704,5 @@ def main(): raise SystemExit(main()) except KeyboardInterrupt: pass + + From 259e37389bd221761f0d45da5c92f4d1ceaa49e3 Mon Sep 17 00:00:00 2001 From: Maciek Ostrowski Date: Sun, 8 Mar 2026 15:07:50 +0000 Subject: [PATCH 3/4] Add Drive folder sync and resizer --- .env.example | 15 + .gitignore | 5 + README.md | 14 +- display_rotator.py | 3 +- photos/.gitkeep | 1 + pr-body.md | 3 +- scripts/drive-sync.py | 259 ++++++++++++++++++ scripts/photo-resize.py | 139 ++++++++++ scripts/photos-shuffle.py | 76 +++-- ...iholestats_v1.1.py => piholestats_v1.3.py} | 89 +----- 10 files changed, 508 insertions(+), 96 deletions(-) create mode 100644 photos/.gitkeep create mode 100644 scripts/drive-sync.py create mode 100644 scripts/photo-resize.py rename scripts/{piholestats_v1.1.py => piholestats_v1.3.py} (87%) diff --git a/.env.example b/.env.example index a62f4bb..877186f 100644 --- a/.env.example +++ b/.env.example @@ -54,12 +54,25 @@ REFRESH_SECS=3 # Cross-midnight windows are supported, e.g. 22,7 means 22:00 through 07:59. ACTIVE_HOURS=22,7 +# --- Local photos / Google Drive sync --- +# Local images used directly by photos-shuffle.py. +LOCAL_PHOTOS_DIR=~/zero2dash/photos +# Shared Google Drive folder ID for drive-sync.py (do not commit your real ID to source control). +GOOGLE_DRIVE_FOLDER_ID= +# Service account JSON used by drive-sync.py. Share the Drive folder directly with this service account email. +GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON=~/zero2dash/drive-service-account.json +# State files used by drive-sync.py and photo-resize.py. +GOOGLE_DRIVE_SYNC_STATE_PATH=~/zero2dash/cache/drive_sync_state.json +PHOTO_RESIZE_STATE_PATH=~/zero2dash/cache/photo_resize_state.json +PHOTO_RESIZE_SCRIPT=~/zero2dash/scripts/photo-resize.py # --- Google Photos (photos-shuffle.py) --- # Required: Album ID to pull shuffled photos from. GOOGLE_PHOTOS_ALBUM_ID=replace-with-google-photos-album-id # OAuth credentials are required by one of these methods: # Use a Desktop OAuth client. If the Google app is in testing, add your account as a consent-screen test user. # Enable Photos Library API in the same Google Cloud project as this OAuth client. +# Since 31 March 2025, Google Photos Library API can only read app-created albums/media. +# For personal/shared albums, pre-populate CACHE_DIR from another source. # 1) Existing client secrets file (default path shown), OR # 2) GOOGLE_PHOTOS_CLIENT_ID + GOOGLE_PHOTOS_CLIENT_SECRET values. GOOGLE_PHOTOS_CLIENT_SECRETS_PATH=~/zero2dash/client_secret.json @@ -84,3 +97,5 @@ LOGO_PATH=/images/goo-photos-icon.png OAUTH_OPEN_BROWSER=0 + + diff --git a/.gitignore b/.gitignore index 6926248..29862e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ __pycache__/ *.pyc .env .env.local +photos/* +!photos/.gitkeep +cache/drive_sync_state.json +cache/photo_resize_state.json + diff --git a/README.md b/README.md index 7c729de..2d511e4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ zero2dash/ ├── display_rotator.py ├── scripts/ │ ├── pihole-display-pre.sh -│ ├── piholestats_v1.1.py # legacy daytime variant +│ ├── piholestats_v1.3.py # always-on daytime variant │ ├── piholestats_v1.2.py # canonical dark-mode service target │ ├── calendash-api.py │ ├── calendash-img.py @@ -106,7 +106,16 @@ Google OAuth notes: - Loopback OAuth only: complete sign-in on the same machine as the script, or tunnel the callback port from a headless Pi with `ssh -L 8080:localhost:8080 pihole@pihole`. - If the Google consent screen is in testing, add your account as a test user. - Enable Google Photos Library API in the same Google Cloud project as the Photos OAuth client. +- Since 31 March 2025, Google Photos Library API only exposes app-created albums/media. Personal or shared albums need a different source, such as a pre-populated local cache used by `photos-shuffle.py` when online fetch is unavailable. - `calendash-api.py` defaults `GOOGLE_TOKEN_PATH` to `token.json` relative to `/opt/zero2dash` under systemd; `photos-shuffle.py` must keep using a separate `GOOGLE_TOKEN_PATH_PHOTOS`. +- For normal personal/shared albums, prefer `LOCAL_PHOTOS_DIR` plus `scripts/drive-sync.py` instead of Google Photos API. +- `scripts/photo-resize.py` resizes changed files in `LOCAL_PHOTOS_DIR` by 50% and is safe to run repeatedly because it tracks processed mtimes in `PHOTO_RESIZE_STATE_PATH`. + +Drive-backed photos: + +- Put display-ready local photos in `~/zero2dash/photos`, or sync them there with `scripts/drive-sync.py`. +- `drive-sync.py` reads `GOOGLE_DRIVE_FOLDER_ID` and `GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON`, downloads image files into `LOCAL_PHOTOS_DIR`, then runs `photo-resize.py`. +- Share the Drive folder directly with the service account email as `Viewer`. Do not rely on link-sharing if you want this to work without resource-key surprises. ## Run via systemd Install and enable canonical units: @@ -131,3 +140,6 @@ journalctl -u pihole-display-dark.service -n 50 --no-pager - `display_rotator.py` excludes `piholestats_v1.2.py` by default so day mode and night mode stay distinct. - Static image scripts (for example `tram-info.py`, `weather-dash.py`, `calendash-img.py`) are rotator-friendly page scripts, not systemd service units by themselves. + + + diff --git a/display_rotator.py b/display_rotator.py index dfd76be..1e66ed7 100644 --- a/display_rotator.py +++ b/display_rotator.py @@ -28,7 +28,7 @@ DEFAULT_PAGES_DIR = "scripts" DEFAULT_PAGE_GLOB = "*.py" -DEFAULT_EXCLUDE_PATTERNS = ["piholestats_v1.2.py", "calendash-api.py", "_config.py"] +DEFAULT_EXCLUDE_PATTERNS = ["piholestats_v1.2.py", "calendash-api.py", "_config.py", "drive-sync.py", "photo-resize.py"] DEFAULT_ROTATE_SECS = 30 SHUTDOWN_WAIT_SECS = 5 DEFAULT_FBDEV = "/dev/fb1" @@ -517,3 +517,4 @@ def request_stop(signum: int, _frame: object) -> None: if __name__ == "__main__": raise SystemExit(main()) + diff --git a/photos/.gitkeep b/photos/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/photos/.gitkeep @@ -0,0 +1 @@ + diff --git a/pr-body.md b/pr-body.md index 58ac5da..4854fcb 100644 --- a/pr-body.md +++ b/pr-body.md @@ -1,9 +1,10 @@ ## Summary - require an explicit scheme for remote Pi-hole hosts and validate TLS/timeout-related configuration -- improve Pi-hole auth handling in `piholestats_v1.1.py`, including v6-session vs legacy-token mode detection and clearer auth/transport failure reporting +- improve Pi-hole auth handling in `piholestats_v1.3.py`, including v6-session vs legacy-token mode detection and clearer auth/transport failure reporting - tighten Google Calendar and Photos OAuth handling around loopback redirects, Desktop OAuth clients, and token scope parsing - add a Photos `--auth-only` mode and improve token-path separation from the calendar flow - update `.env.example` and `README.md` to document the revised Pi-hole and Google OAuth setup ## Testing - not run from this Codex environment + diff --git a/scripts/drive-sync.py b/scripts/drive-sync.py new file mode 100644 index 0000000..0706a73 --- /dev/null +++ b/scripts/drive-sync.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Sync image files from a shared Google Drive folder into LOCAL_PHOTOS_DIR.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request as UrlRequest, urlopen + +from dotenv import load_dotenv +from google.auth.transport.requests import Request +from google.oauth2 import service_account + +from _config import get_env, report_validation_errors + +DEFAULT_ROOT = Path("~/zero2dash").expanduser() +DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.readonly"] +DEFAULT_STATE_PATH = DEFAULT_ROOT / "cache" / "drive_sync_state.json" +DEFAULT_RESIZE_SCRIPT = DEFAULT_ROOT / "scripts" / "photo-resize.py" +ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} + + +@dataclass +class Config: + folder_id: str + service_account_json: Path + local_photos_dir: Path + state_path: Path + resize_script: Path + skip_resize: bool + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync a shared Google Drive folder into LOCAL_PHOTOS_DIR.") + parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit") + parser.add_argument("--skip-resize", action="store_true", help="Skip the follow-up resize step") + return parser.parse_args() + + +def validate_config(skip_resize: bool) -> tuple[Config | None, list[str]]: + errors: list[str] = [] + + def record(name: str, *, default: Any = None, required: bool = False) -> Any: + try: + return get_env(name, default=default, required=required) + except ValueError as exc: + errors.append(str(exc)) + return default + + folder_id = str(record("GOOGLE_DRIVE_FOLDER_ID", required=True)) + service_account_raw = str(record("GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON", default=str(DEFAULT_ROOT / "drive-service-account.json"))) + local_photos_raw = str(record("LOCAL_PHOTOS_DIR", default=str(DEFAULT_ROOT / "photos"))) + state_raw = str(record("GOOGLE_DRIVE_SYNC_STATE_PATH", default=str(DEFAULT_STATE_PATH))) + resize_raw = str(record("PHOTO_RESIZE_SCRIPT", default=str(DEFAULT_RESIZE_SCRIPT))) + + service_account_json = Path(service_account_raw).expanduser() + if not service_account_json.is_file(): + errors.append(f"GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON not found: {service_account_json}") + + config = Config( + folder_id=folder_id, + service_account_json=service_account_json, + local_photos_dir=Path(local_photos_raw).expanduser(), + state_path=Path(state_raw).expanduser(), + resize_script=Path(resize_raw).expanduser(), + skip_resize=skip_resize, + ) + + if errors: + return None, errors + return config, [] + + +def load_config(skip_resize: bool) -> Config: + load_dotenv(DEFAULT_ROOT / ".env") + config, errors = validate_config(skip_resize) + if errors: + report_validation_errors("drive-sync.py", errors) + raise ValueError("Invalid configuration") + assert config is not None + return config + + +def load_state(path: Path) -> dict[str, dict[str, str]]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def save_state(path: Path, state: dict[str, dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") + + +def build_credentials(config: Config): + creds = service_account.Credentials.from_service_account_file( + str(config.service_account_json), + scopes=DRIVE_SCOPES, + ) + creds.refresh(Request()) + return creds + + +def drive_json(creds, endpoint: str, params: dict[str, str]) -> dict[str, Any]: + creds.refresh(Request()) + url = f"https://www.googleapis.com/drive/v3/{endpoint}?{urlencode(params)}" + req = UrlRequest(url, headers={"Authorization": f"Bearer {creds.token}"}) + try: + with urlopen(req, timeout=30) as response: # nosec B310 + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + raise RuntimeError(f"Google Drive API request failed ({exc.code} {exc.reason}): {body}") from exc + if not isinstance(payload, dict): + raise ValueError("Google Drive API response must be a JSON object") + return payload + + +def list_folder_images(creds, folder_id: str) -> list[dict[str, str]]: + images: list[dict[str, str]] = [] + page_token = "" + while True: + params = { + "q": f"'{folder_id}' in parents and trashed = false and mimeType contains 'image/'", + "fields": "nextPageToken,files(id,name,mimeType,modifiedTime)", + "pageSize": "1000", + "supportsAllDrives": "true", + "includeItemsFromAllDrives": "true", + } + if page_token: + params["pageToken"] = page_token + payload = drive_json(creds, "files", params) + files = payload.get("files", []) + if not isinstance(files, list): + raise ValueError("Google Drive API response field 'files' must be a list") + for item in files: + if isinstance(item, dict): + images.append({ + "id": str(item.get("id") or ""), + "name": str(item.get("name") or "image"), + "mimeType": str(item.get("mimeType") or "image/jpeg"), + "modifiedTime": str(item.get("modifiedTime") or ""), + }) + page_token = str(payload.get("nextPageToken") or "") + if not page_token: + break + return [item for item in images if item["id"]] + + +def safe_stem(name: str) -> str: + stem = Path(name).stem or "image" + return re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._") or "image" + + +def suffix_for_item(item: dict[str, str]) -> str: + suffix = Path(item["name"]).suffix.lower() + if suffix in ALLOWED_SUFFIXES: + return suffix + guessed = (mimetypes.guess_extension(item["mimeType"]) or ".jpg").lower() + return ".jpg" if guessed == ".jpe" else guessed + + +def local_path_for_item(local_dir: Path, item: dict[str, str]) -> Path: + return local_dir / f"drive-{item['id']}-{safe_stem(item['name'])}{suffix_for_item(item)}" + + +def download_file(creds, file_id: str, target: Path) -> None: + creds.refresh(Request()) + url = f"https://www.googleapis.com/drive/v3/files/{file_id}?alt=media&supportsAllDrives=true" + req = UrlRequest(url, headers={"Authorization": f"Bearer {creds.token}"}) + with urlopen(req, timeout=60) as response: # nosec B310 + data = response.read() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + +def run_resize(config: Config) -> None: + if config.skip_resize: + print("[drive-sync.py] Resize step skipped (--skip-resize).") + return + if not config.resize_script.is_file(): + print(f"[drive-sync.py] Resize script not found: {config.resize_script}; skipping resize step.") + return + result = subprocess.run([sys.executable, str(config.resize_script)], check=False) + if result.returncode != 0: + raise RuntimeError(f"Resize step failed with exit code {result.returncode}") + + +def sync_drive(config: Config) -> None: + creds = build_credentials(config) + items = list_folder_images(creds, config.folder_id) + config.local_photos_dir.mkdir(parents=True, exist_ok=True) + + state = load_state(config.state_path) + next_state: dict[str, dict[str, str]] = {} + downloaded = 0 + skipped = 0 + removed = 0 + + for item in items: + target = local_path_for_item(config.local_photos_dir, item) + previous = state.get(item["id"], {}) + previous_name = previous.get("local_name", "") + previous_target = config.local_photos_dir / previous_name if previous_name else None + if previous_target and previous_target != target and previous_target.exists() and previous_target.name.startswith("drive-"): + previous_target.unlink() + if previous.get("modifiedTime") == item["modifiedTime"] and target.exists(): + skipped += 1 + else: + download_file(creds, item["id"], target) + downloaded += 1 + next_state[item["id"]] = { + "modifiedTime": item["modifiedTime"], + "local_name": target.name, + } + + stale_ids = set(state) - set(next_state) + for stale_id in stale_ids: + stale_name = state.get(stale_id, {}).get("local_name", "") + stale_path = config.local_photos_dir / stale_name if stale_name else None + if stale_path and stale_path.exists() and stale_path.name.startswith("drive-"): + stale_path.unlink() + removed += 1 + + save_state(config.state_path, next_state) + print(f"[drive-sync.py] Synced {len(items)} images: downloaded={downloaded}, skipped={skipped}, removed={removed}.") + run_resize(config) + + +def main() -> int: + args = parse_args() + try: + config = load_config(skip_resize=args.skip_resize) + except ValueError: + return 1 + + if args.check_config: + print("[drive-sync.py] Configuration check passed.") + return 0 + + sync_drive(config) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/photo-resize.py b/scripts/photo-resize.py new file mode 100644 index 0000000..de078d2 --- /dev/null +++ b/scripts/photo-resize.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Resize changed images in LOCAL_PHOTOS_DIR to 50% of their current dimensions.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from PIL import Image, ImageOps + +from _config import get_env, report_validation_errors + +DEFAULT_ROOT = Path("~/zero2dash").expanduser() +DEFAULT_STATE_PATH = DEFAULT_ROOT / "cache" / "photo_resize_state.json" +ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} +RESIZE_SCALE = 0.5 + + +@dataclass +class Config: + local_photos_dir: Path + state_path: Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Resize changed photos in LOCAL_PHOTOS_DIR by 50%.") + parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit") + return parser.parse_args() + + +def validate_config() -> tuple[Config | None, list[str]]: + errors: list[str] = [] + + def record(name: str, *, default: Any = None, required: bool = False) -> Any: + try: + return get_env(name, default=default, required=required) + except ValueError as exc: + errors.append(str(exc)) + return default + + local_photos_raw = str(record("LOCAL_PHOTOS_DIR", default=str(DEFAULT_ROOT / "photos"))) + state_raw = str(record("PHOTO_RESIZE_STATE_PATH", default=str(DEFAULT_STATE_PATH))) + + config = Config( + local_photos_dir=Path(local_photos_raw).expanduser(), + state_path=Path(state_raw).expanduser(), + ) + if errors: + return None, errors + return config, [] + + +def load_state(path: Path) -> dict[str, dict[str, int]]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def save_state(path: Path, state: dict[str, dict[str, int]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") + + +def iter_images(local_photos_dir: Path) -> list[Path]: + if not local_photos_dir.exists(): + return [] + return sorted( + path + for path in local_photos_dir.rglob("*") + if path.is_file() and path.suffix.lower() in ALLOWED_SUFFIXES + ) + + +def resize_in_place(path: Path) -> None: + with Image.open(path) as raw: + image = ImageOps.exif_transpose(raw) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGB") + width = max(1, int(round(image.width * RESIZE_SCALE))) + height = max(1, int(round(image.height * RESIZE_SCALE))) + resized = image.resize((width, height), Image.Resampling.LANCZOS) + save_kwargs: dict[str, Any] = {} + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + if resized.mode != "RGB": + resized = resized.convert("RGB") + save_kwargs = {"quality": 90, "optimize": True} + elif suffix == ".png": + save_kwargs = {"optimize": True} + resized.save(path, **save_kwargs) + + +def main() -> int: + args = parse_args() + load_dotenv(DEFAULT_ROOT / ".env") + config, errors = validate_config() + if errors: + report_validation_errors("photo-resize.py", errors) + return 1 + assert config is not None + + if args.check_config: + print("[photo-resize.py] Configuration check passed.") + return 0 + + config.local_photos_dir.mkdir(parents=True, exist_ok=True) + state = load_state(config.state_path) + next_state: dict[str, dict[str, int]] = {} + processed = 0 + skipped = 0 + + for image_path in iter_images(config.local_photos_dir): + stat = image_path.stat() + key = str(image_path.relative_to(config.local_photos_dir)) + current = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns} + if state.get(key) == current: + next_state[key] = current + skipped += 1 + continue + resize_in_place(image_path) + updated = image_path.stat() + next_state[key] = {"size": updated.st_size, "mtime_ns": updated.st_mtime_ns} + processed += 1 + + save_state(config.state_path, next_state) + print(f"[photo-resize.py] Processed={processed}, skipped={skipped}, total={processed + skipped}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/photos-shuffle.py b/scripts/photos-shuffle.py index be8691f..89af9af 100755 --- a/scripts/photos-shuffle.py +++ b/scripts/photos-shuffle.py @@ -2,15 +2,14 @@ """ photos-shuffle.py -One-shot Google Photos album renderer for a 320x240 framebuffer page. +One-shot photo renderer for a 320x240 framebuffer page. Requirements (pip): Pillow, python-dotenv, google-auth, google-auth-oauthlib, google-api-python-client. Configuration (.env): -- Required: GOOGLE_PHOTOS_ALBUM_ID -- Optional: GOOGLE_PHOTOS_CLIENT_SECRETS_PATH, GOOGLE_TOKEN_PATH_PHOTOS, FB_DEVICE, WIDTH, - HEIGHT, CACHE_DIR, FALLBACK_IMAGE, LOGO_PATH +- Optional: LOCAL_PHOTOS_DIR, GOOGLE_PHOTOS_ALBUM_ID, GOOGLE_PHOTOS_CLIENT_SECRETS_PATH, + GOOGLE_TOKEN_PATH_PHOTOS, FB_DEVICE, WIDTH, HEIGHT, CACHE_DIR, FALLBACK_IMAGE, LOGO_PATH - Optional OAuth alternative: GOOGLE_PHOTOS_CLIENT_ID and GOOGLE_PHOTOS_CLIENT_SECRET (used when GOOGLE_PHOTOS_CLIENT_SECRETS_PATH file is not present). @@ -26,11 +25,15 @@ script, or use SSH port forwarding to forward the callback port from the Pi. - Use a Desktop OAuth client. If the Google app is in testing, add your account as a test user before first run. +- Since 31 March 2025, Google Photos Library API read access is limited to app-created + albums and media items. Personal/shared albums should use LOCAL_PHOTOS_DIR, + optionally populated by Google Drive sync. Fallback: +- Preferred source is LOCAL_PHOTOS_DIR (default: ~/zero2dash/photos). If it is empty, the + script can still try Google Photos when configured, then CACHE_DIR, then FALLBACK_IMAGE. - Ensure local fallback image exists at ~/zero2dash/images/photos-fallback.png - (or override with FALLBACK_IMAGE). If online/offline fetch fails, fallback is - rendered through the same processing pipeline. + (or override with FALLBACK_IMAGE). """ from __future__ import annotations @@ -52,7 +55,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow from PIL import Image, ImageEnhance -SCOPES = ["https://www.googleapis.com/auth/photoslibrary.readonly"] +SCOPES = ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"] DEFAULT_ROOT = Path("~/zero2dash").expanduser() TEST_OUTPUT = Path("/tmp/photos-shuffle-test.png") LOGO_WIDTH_RATIO = 0.14 @@ -62,6 +65,7 @@ @dataclass class Config: + local_photos_dir: Path album_id: str client_secrets_path: Path client_id: str @@ -161,7 +165,8 @@ def record(name: str, *, default: Any = None, required: bool = False, validator: errors.append(str(exc)) return default - album_id = record("GOOGLE_PHOTOS_ALBUM_ID", required=True) + local_photos_raw = record("LOCAL_PHOTOS_DIR", default=str(DEFAULT_ROOT / "photos")) + album_id = record("GOOGLE_PHOTOS_ALBUM_ID", default="") client_secrets_raw = record("GOOGLE_PHOTOS_CLIENT_SECRETS_PATH", default=str(DEFAULT_ROOT / "client_secret.json")) client_id = record("GOOGLE_PHOTOS_CLIENT_ID") or record("GOOGLE_CLIENT_ID") or "" @@ -189,6 +194,7 @@ def record(name: str, *, default: Any = None, required: bool = False, validator: errors.append(f"FALLBACK_IMAGE not found: {fallback_image}") config = Config( + local_photos_dir=Path(str(local_photos_raw)).expanduser(), album_id=str(album_id), client_secrets_path=Path(str(client_secrets_raw)).expanduser(), client_id=str(client_id), @@ -205,13 +211,13 @@ def record(name: str, *, default: Any = None, required: bool = False, validator: ) calendar_default_token = (DEFAULT_ROOT / "token.json").resolve() - if config.token_path.resolve() == calendar_default_token: + if config.album_id and config.token_path.resolve() == calendar_default_token: errors.append( "GOOGLE_TOKEN_PATH_PHOTOS points to token.json, which is reserved for calendash-api.py. " "Use a separate photos token path (default: ~/zero2dash/token_photos.json)." ) - if not config.client_secrets_path.exists() and not (config.client_id and config.client_secret): + if config.album_id and not config.client_secrets_path.exists() and not (config.client_id and config.client_secret): errors.append( f"Google Photos OAuth credentials are required: set GOOGLE_PHOTOS_CLIENT_SECRETS_PATH to an existing file " f"or provide GOOGLE_PHOTOS_CLIENT_ID + GOOGLE_PHOTOS_CLIENT_SECRET (checked path: {config.client_secrets_path})." @@ -236,7 +242,7 @@ def authenticate(config: Config, log: Log) -> Credentials: creds: Credentials | None = None calendar_default_token = (DEFAULT_ROOT / "token.json").resolve() - if config.token_path.resolve() == calendar_default_token: + if config.album_id and config.token_path.resolve() == calendar_default_token: raise ValueError( "GOOGLE_TOKEN_PATH_PHOTOS points to token.json, which is reserved for calendash-api.py. " "Use a separate photos token path (default: ~/zero2dash/token_photos.json)." @@ -251,7 +257,7 @@ def authenticate(config: Config, log: Log) -> Credentials: if payload and not _is_token_compatible_with_photos(payload): log.info( - f"Token at {config.token_path} does not include Google Photos scope; re-authenticating" + f"Token at {config.token_path} does not include required Google Photos scope {SCOPES[0]}; re-authenticating" ) else: try: @@ -357,6 +363,15 @@ def _describe_google_api_error(exc: HTTPError) -> str: return f"Google Photos API request failed ({exc.code} {exc.reason})" message = str(error.get("message") or f"Google Photos API request failed ({exc.code} {exc.reason})").strip() + lowered = message.lower() + if "insufficient authentication scopes" in lowered: + return ( + "Google Photos Library API removed photoslibrary.readonly on 31 March 2025. " + "This script now only works with app-created albums/media using " + "photoslibrary.readonly.appcreateddata. Re-authorise with the new scope, " + "or populate CACHE_DIR from another source for personal/shared albums." + ) + details = error.get("details") if not isinstance(details, list): return message @@ -580,7 +595,18 @@ def write_framebuffer(img: Image.Image, fb_device: str, width: int, height: int) fb.write(payload) +def choose_local_image(config: Config, log: Log) -> Path: + local_images = list_cached_images(config.local_photos_dir) + if not local_images: + raise RuntimeError(f"Local photos directory empty: {config.local_photos_dir}") + chosen = random.choice(local_images) + log.info(f"LOCAL: selected {chosen.name}") + return chosen + def choose_online_image(config: Config, log: Log) -> Path: + if not config.album_id: + raise RuntimeError("GOOGLE_PHOTOS_ALBUM_ID not configured") + creds = authenticate(config, log) items = list_album_images(creds, config.album_id, log) if not items: @@ -648,20 +674,27 @@ def main() -> int: return 0 if args.auth_only: + if not config.album_id: + print("[photos-shuffle.py] No Google Photos album configured; auth check skipped.") + return 0 authenticate(config, log) print("[photos-shuffle.py] Authentication check passed.") return 0 source_image: Path | None = None try: - source_image = choose_online_image(config, log) - except Exception as exc: - log.info(f"Online unavailable ({exc}); trying offline cache") + source_image = choose_local_image(config, log) + except Exception as local_exc: + log.info(f"Local photos unavailable ({local_exc}); trying online source") try: - source_image = choose_offline_image(config, log) - except Exception as off_exc: - log.info(f"Offline cache unavailable ({off_exc}); using fallback image") - source_image = config.fallback_image + source_image = choose_online_image(config, log) + except Exception as exc: + log.info(f"Online unavailable ({exc}); trying offline cache") + try: + source_image = choose_offline_image(config, log) + except Exception as off_exc: + log.info(f"Offline cache unavailable ({off_exc}); using fallback image") + source_image = config.fallback_image try: frame = composite_frame(source_image, config.logo_path, config.width, config.height) @@ -697,3 +730,8 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) + + + + + diff --git a/scripts/piholestats_v1.1.py b/scripts/piholestats_v1.3.py similarity index 87% rename from scripts/piholestats_v1.1.py rename to scripts/piholestats_v1.3.py index 389d108..217a01f 100644 --- a/scripts/piholestats_v1.1.py +++ b/scripts/piholestats_v1.3.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 # Pi-hole TFT Dashboard -> direct framebuffer RGB565 (no X, no SDL) # v6 auth handled elsewhere; this file only renders and calls API -# Version 1.1 - Introducing dark mode -# LEGACY: kept for compatibility/manual use; canonical night service uses piholestats_v1.2.py +# Version 1.3 - Always-on stats display +# Manual always-on variant; canonical night service uses piholestats_v1.2.py import os, sys, time, json, urllib.request, urllib.parse, urllib.error, mmap, struct, argparse, ssl, errno from pathlib import Path @@ -18,7 +18,6 @@ FBDEV = "/dev/fb1" W, H = 320, 240 REFRESH_SECS = 3 -ACTIVE_HOURS = (7, 22) PIHOLE_HOST = "127.0.0.1" PIHOLE_SCHEME = "" @@ -54,17 +53,6 @@ def _parse_int(value: str) -> int: raise ValueError(f"expected integer, got {value!r}") from exc -def _parse_active_hours(value: str) -> tuple[int, int]: - parts = [part.strip() for part in value.split(",")] - if len(parts) != 2: - raise ValueError("expected format start,end (e.g. 22,7)") - try: - start_hour, end_hour = int(parts[0]), int(parts[1]) - except ValueError as exc: - raise ValueError(f"expected integers in start,end, got {value!r}") from exc - for hour in (start_hour, end_hour): - validate_hour_bounds(hour) - return start_hour, end_hour def _parse_float(value: str) -> float: @@ -101,43 +89,10 @@ def _host_requires_explicit_scheme(raw_host: str) -> bool: return hostname not in {"localhost", "::1"} and not hostname.startswith("127.") -def validate_hour_bounds(hour: int) -> None: - if hour < 0 or hour > 23: - raise ValueError(f"hour must be in range 0-23, got {hour}") - - -def is_hour_active(now_hour: int, start: int, end: int) -> bool: - validate_hour_bounds(now_hour) - validate_hour_bounds(start) - validate_hour_bounds(end) - if start <= end: - return start <= now_hour <= end - return now_hour >= start or now_hour <= end - - -def run_self_checks() -> None: - # Non-wrapping range. - assert is_hour_active(8, 8, 17) - assert is_hour_active(17, 8, 17) - assert not is_hour_active(7, 8, 17) - # Cross-midnight range. - assert is_hour_active(23, 22, 7) - assert is_hour_active(3, 22, 7) - assert not is_hour_active(12, 22, 7) - # Single-hour range. - assert is_hour_active(0, 0, 0) - assert not is_hour_active(1, 0, 0) - # Late-night to early-morning range. - assert is_hour_active(23, 23, 2) - assert is_hour_active(1, 23, 2) - assert not is_hour_active(10, 23, 2) - - for invalid_hour in (-1, 24): - try: - is_hour_active(invalid_hour, 8, 17) - raise AssertionError("expected ValueError for out-of-range hour") - except ValueError: - pass + + + + def validate_config() -> tuple[dict[str, object] | None, list[str]]: @@ -159,7 +114,6 @@ def record(name: str, *, default=None, required=False, validator=None): api_token = record("PIHOLE_API_TOKEN", default="") refresh_secs = record("REFRESH_SECS", default=REFRESH_SECS, validator=_parse_int) request_timeout = record("PIHOLE_TIMEOUT", default=REQUEST_TIMEOUT, validator=_parse_float) - active_hours = record("ACTIVE_HOURS", default=f"{ACTIVE_HOURS[0]},{ACTIVE_HOURS[1]}", validator=_parse_active_hours) if isinstance(refresh_secs, int) and refresh_secs < 1: errors.append("REFRESH_SECS is invalid: must be greater than or equal to 1") @@ -192,21 +146,19 @@ def record(name: str, *, default=None, required=False, validator=None): "pihole_auth_mode": auth_mode, "request_timeout": float(request_timeout), "refresh_secs": int(refresh_secs), - "active_hours": active_hours if isinstance(active_hours, tuple) else ACTIVE_HOURS, }, [] def parse_args(): parser = argparse.ArgumentParser(description="Pi-hole framebuffer dashboard") parser.add_argument("--check-config", action="store_true", help="Validate env configuration and exit") - parser.add_argument("--self-test", action="store_true", help="Run active-hours self checks and exit") parser.add_argument("--output-image", default="", help="Write rendered frame to a PNG file instead of framebuffer") return parser.parse_args() def apply_config(config: dict[str, object], output_image: str = "") -> None: global FBDEV, PIHOLE_HOST, PIHOLE_SCHEME, PIHOLE_VERIFY_TLS, PIHOLE_CA_BUNDLE - global PIHOLE_PASSWORD, PIHOLE_API_TOKEN, PIHOLE_AUTH_MODE, REFRESH_SECS, ACTIVE_HOURS, BASE_URL + global PIHOLE_PASSWORD, PIHOLE_API_TOKEN, PIHOLE_AUTH_MODE, REFRESH_SECS, BASE_URL global REQUEST_TIMEOUT, REQUEST_TLS_VERIFY, OUTPUT_IMAGE FBDEV = str(config["fbdev"]) PIHOLE_HOST = str(config["pihole_host"]) @@ -218,7 +170,6 @@ def apply_config(config: dict[str, object], output_image: str = "") -> None: PIHOLE_AUTH_MODE = str(config["pihole_auth_mode"]) REQUEST_TIMEOUT = float(config["request_timeout"]) REFRESH_SECS = int(config["refresh_secs"]) - ACTIVE_HOURS = config["active_hours"] OUTPUT_IMAGE = output_image.strip() or str(get_env("OUTPUT_IMAGE", default="")).strip() BASE_URL = _normalize_host(PIHOLE_HOST, preferred_scheme=PIHOLE_SCHEME) REQUEST_TLS_VERIFY = _resolve_tls_verify(BASE_URL, PIHOLE_VERIFY_TLS, PIHOLE_CA_BUNDLE) @@ -481,7 +432,7 @@ def fetch_pihole(): fallback_summary = legacy.get("failure", {}).get("summary") or legacy.get("status", "LEGACY FAIL") print( - f"[piholestats_v1.1.py] Summary fetch failed. primary={primary_summary}; fallback={fallback_summary}", + f"[piholestats_v1.3.py] Summary fetch failed. primary={primary_summary}; fallback={fallback_summary}", file=sys.stderr, ) return { @@ -604,7 +555,7 @@ def draw_temp_value(d, rect, temp_c, font, colour): d.text((x, y), num, font=font, fill=colour) -def draw_frame(stats, temp_c, uptime, active): +def draw_frame(stats, temp_c, uptime): img = Image.new("RGB", (W, H), COL_BG) d = ImageDraw.Draw(img) big = load_font(28, True) @@ -629,12 +580,6 @@ def tile(x, y, color, title, value, val_font, value_is_temp=False): tw, th = text_size(d, value, val_font) d.text((r[0]+(r[2]-tw)//2, r[1]+(r[3]-th)//2), value, font=val_font, fill=COL_TXT) - if not active: - d.rounded_rectangle([margin, margin, W-margin, H-margin], radius=12, fill=(20,20,20)) - msg = f"{TITLE}: Sleeping" - tw, th = text_size(d, msg, mid) - d.text(((W-tw)//2,(H-th)//2), msg, font=mid, fill=(180,180,180)) - return img total = stats["total"] blocked = stats["blocked"] @@ -661,20 +606,16 @@ def tile(x, y, color, title, value, val_font, value_is_temp=False): def main(): args = parse_args() - if args.self_test: - run_self_checks() - print(f"[piholestats_v1.1.py] Self checks passed.") - return 0 load_dotenv(DEFAULT_ROOT / '.env') config, errors = validate_config() if errors: - report_validation_errors("piholestats_v1.1.py", errors) + report_validation_errors("piholestats_v1.3.py", errors) return 1 assert config is not None if args.check_config: - print("[piholestats_v1.1.py] Configuration check passed.") + print("[piholestats_v1.3.py] Configuration check passed.") return 0 apply_config(config, output_image=args.output_image) @@ -690,8 +631,6 @@ def main(): pass while True: - hr = time.localtime().tm_hour - active = is_hour_active(hr, ACTIVE_HOURS[0], ACTIVE_HOURS[1]) s = fetch_pihole() if s["ok"]: @@ -700,10 +639,10 @@ def main(): temp_c = read_temp_c() uptime = read_uptime_str() - frame = draw_frame(cached, temp_c, uptime, active) + frame = draw_frame(cached, temp_c, uptime) fb_write(frame) if OUTPUT_IMAGE: - print(f"[piholestats_v1.1.py] Rendered test frame to {OUTPUT_IMAGE}.") + print(f"[piholestats_v1.3.py] Rendered test frame to {OUTPUT_IMAGE}.") return 0 time.sleep(REFRESH_SECS) @@ -716,3 +655,5 @@ def main(): pass + + From 1ce74ddcf8eb55fb9a3975f65e2ceae0298cab09 Mon Sep 17 00:00:00 2001 From: Codex <242516109+Codex@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:13:15 +0000 Subject: [PATCH 4/4] Initial plan (#87) Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>