Skip to content
Merged
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
17 changes: 14 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,26 @@ 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)
- `FB_DEVICE` (optional override; defaults to `/dev/fb1`)
- `FB_WIDTH` / `FB_HEIGHT` (optional override for static renderer geometry; defaults `320x240`)

## 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
Expand Down
46 changes: 23 additions & 23 deletions scripts/calendash-api.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@


def _normalize_scopes(raw_scopes: Any) -> set[str]:
parsed_scopes: set[str] = set()
if isinstance(raw_scopes, str):
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()

def _add_scope_values(raw_value: Any) -> None:
if raw_value is None:
Expand Down Expand Up @@ -456,6 +460,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} <user>@<pi-host>",
"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)
Expand Down Expand Up @@ -552,28 +566,14 @@ def get_credentials(
raise RuntimeError(
"Authenticated token does not include required calendar scopes. Ensure consent grants calendar.readonly access."
)
persist_auth_diagnostics(
diagnostics_path,
AuthAttemptDiagnostics(status="success", mode=auth_mode, oauth_port=oauth_port, redirect_uri=redirect_uri),
)
except Exception as exc:
next_steps = _next_steps_for_auth_error(auth_mode, exc, redirect_uri, oauth_port)
persist_auth_diagnostics(
diagnostics_path,
AuthAttemptDiagnostics(
status="failed",
mode=auth_mode,
oauth_port=oauth_port,
redirect_uri=redirect_uri,
error_type=type(exc).__name__,
error_message=str(exc),
next_steps=next_steps,
),
)
logging.error("OAuth flow '%s' failed: %s", auth_mode, exc)
for step in next_steps:
logging.error("Next step: %s", step)
raise
if any(tag in exc_text for tag in ["access blocked", "app is blocked", "app restricted", "invalid_client"]):
logging.error(
"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.")
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

Expand Down
86 changes: 58 additions & 28 deletions scripts/photos-shuffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,7 +176,7 @@ def _verify_token_metadata(token_path: Path, force_token_path_reuse: bool) -> No

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()
Expand All @@ -199,16 +201,40 @@ def debug(self, message: str) -> None:
print(f"[debug] {message}")


class MediaDownloadError(RuntimeError):
pass


class MediaDownloadPermanentError(MediaDownloadError):
pass
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),
],
}
}


class MediaDownloadTransientError(MediaDownloadError):
pass
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} <user>@<pi-host>",
"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:
Expand Down Expand Up @@ -353,8 +379,12 @@ def load_config() -> Config:
def authenticate(config: Config, log: Log, *, force_token_path_reuse: bool = False) -> Credentials:
creds: Credentials | None = None

_preflight_token_path_guard(config.token_path, force_token_path_reuse)
_verify_token_metadata(config.token_path, force_token_path_reuse)
calendar_default_token = (DEFAULT_ROOT / "token.json").resolve()
if 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)."
)

if config.token_path.exists():
try:
Expand Down Expand Up @@ -390,20 +420,13 @@ def authenticate(config: Config, log: Log, *, force_token_path_reuse: bool = Fal
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:
Expand All @@ -417,13 +440,18 @@ def authenticate(config: Config, log: Log, *, force_token_path_reuse: bool = Fal
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

_write_token_atomically(config.token_path, creds.to_json())
_write_token_metadata(config.token_path, provider="google_photos", scopes=SCOPES)
Expand Down Expand Up @@ -767,11 +795,8 @@ 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 config (including credential-source precedence) and exit",
)
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")
parser.add_argument(
"--force-token-path-reuse",
Expand Down Expand Up @@ -812,6 +837,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, force_token_path_reuse=args.force_token_path_reuse)
Expand Down
Loading