From 90b692da3918629f39982a4446e1da897c531790 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 3 May 2026 11:22:54 +0100 Subject: [PATCH] Move secrets and URLs out of source into .env Adds a stdlib-only loader (src/lib/env.py) that walks up to find the nearest .env. display.py and notebooks/_helpers.py now `require()` the config values; wifi-check.sh sources .env to derive its probe host. The .env file is gitignored; .env.example documents the required keys. The existing tokens are still present in git history and will be scrubbed in the next commit; rotate them after the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 4 ++++ .gitignore | 2 ++ CLAUDE.md | 2 +- notebooks/_helpers.py | 12 +++++------- src/display.py | 15 ++++++--------- src/lib/env.py | 43 +++++++++++++++++++++++++++++++++++++++++++ src/wifi-check.sh | 8 +++++++- sync.sh | 2 +- 8 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 .env.example create mode 100644 src/lib/env.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2eb5e8e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +IMMICH_URL=https://immich.example.com +IMMICH_API_KEY=your-immich-api-key +HA_URL=https://homeassistant.example.com +HA_TOKEN=your-home-assistant-long-lived-token diff --git a/.gitignore b/.gitignore index d55f838..2f2026c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ __pycache__/ *.egg-info/ .ipynb_checkpoints/ photo_history.json +.env + diff --git a/CLAUDE.md b/CLAUDE.md index 561ca62..489b712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ former `dither_test/`): - **Display refresh takes 12-15 seconds** — the BUSY pin polling handles this - **No test suite** — this is a hardware project; test by deploying to the Pi - **Dependencies on Pi**: `python3-pil python3-numba python3-smbus spidev gpiozero` -- **Config via environment variables**: `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN` (with hardcoded defaults in display.py) +- **Config via `.env`** (gitignored): `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN`. Loaded by `src/lib/env.py` (stdlib-only); `require(key)` raises if a value is missing. Copy `.env.example` to `.env` and fill in. The Pi keeps its own `~/frame/.env` — `sync.sh` excludes it. - **Uses only stdlib `urllib`** — no requests library; the Immich client uses `urllib.request` directly - **Single-instance lock** at `/tmp/frame.lock` (fcntl) — overlapping cron runs exit cleanly - `sys.path.append` is used to add `lib/` to the path from display.py diff --git a/notebooks/_helpers.py b/notebooks/_helpers.py index 5b5cf2a..d5df9f6 100644 --- a/notebooks/_helpers.py +++ b/notebooks/_helpers.py @@ -9,7 +9,6 @@ from __future__ import annotations import contextlib import io -import os import random import sys import tempfile @@ -21,8 +20,6 @@ REPO = Path(__file__).resolve().parent.parent CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook" DEFAULT_PEOPLE = ("Me", "Ruby") -DEFAULT_IMMICH_URL = "https://immich.example.com" -DEFAULT_IMMICH_API_KEY = "REDACTED_IMMICH_API_KEY" def bootstrap() -> None: @@ -32,15 +29,16 @@ def bootstrap() -> None: if sp not in sys.path: sys.path.insert(0, sp) sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig")) + from env import load_env + + load_env() def immich_client(): + from env import require from immich import ImmichClient - return ImmichClient( - os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL), - os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY), - ) + return ImmichClient(require("IMMICH_URL"), require("IMMICH_API_KEY")) def is_landscape(asset: dict) -> bool: diff --git a/src/display.py b/src/display.py index 5bdc8ce..b79558b 100644 --- a/src/display.py +++ b/src/display.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse import fcntl -import os import sys from datetime import datetime from pathlib import Path @@ -10,6 +9,7 @@ from PIL import Image sys.path.append(str(Path(__file__).parent / "lib")) from crop import face_aware_crop +from env import load_env, require from homeassistant import HomeAssistantClient from immich import ImmichClient, get_random_photo_from_album, get_random_photo_of_people from overlay import format_age, format_location @@ -18,14 +18,11 @@ from overlay import format_age, format_location # GPIO pins at import time, so two overlapping invocations would both crash # on "GPIO busy" before reaching the flock below. -IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.example.com") -IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY") - -HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com") -HA_TOKEN = os.environ.get( - "HA_TOKEN", - "REDACTED_HA_TOKEN", -) +load_env() +IMMICH_URL = require("IMMICH_URL") +IMMICH_API_KEY = require("IMMICH_API_KEY") +HA_URL = require("HA_URL") +HA_TOKEN = require("HA_TOKEN") HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"} diff --git a/src/lib/env.py b/src/lib/env.py new file mode 100644 index 0000000..943617b --- /dev/null +++ b/src/lib/env.py @@ -0,0 +1,43 @@ +"""Minimal stdlib .env loader. + +Reads KEY=VALUE lines from a .env file (project root by default) into +`os.environ`, leaving already-set variables untouched. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _find_env() -> Path | None: + # Walk up from this file and from cwd; first .env wins. Handles both the + # dev layout (src/lib/env.py with .env at repo root) and the Pi layout + # (lib/env.py with .env at ~/frame/.env). + for start in (Path(__file__).resolve().parent, Path.cwd().resolve()): + for d in (start, *start.parents): + candidate = d / ".env" + if candidate.is_file(): + return candidate + return None + + +def load_env(path: Path | None = None) -> None: + env_path = path or _find_env() + if env_path is None or not env_path.exists(): + return + for raw in env_path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def require(key: str) -> str: + value = os.environ.get(key) + if not value: + raise RuntimeError(f"missing required env var: {key} (set it in .env or the environment)") + return value diff --git a/src/wifi-check.sh b/src/wifi-check.sh index 88a65f8..d6dfd03 100755 --- a/src/wifi-check.sh +++ b/src/wifi-check.sh @@ -4,7 +4,13 @@ # brcmfmac chip on the Pi Zero 2W. CONNECTION="netplan-wlan0-HiddenPlace" -PROBE_HOST="homeassistant.example.com" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +[ -f "$SCRIPT_DIR/.env" ] && set -a && . "$SCRIPT_DIR/.env" && set +a +PROBE_HOST="${HA_URL#http*://}" +PROBE_HOST="${PROBE_HOST%%/*}" +: "${PROBE_HOST:?HA_URL must be set in .env}" probe() { ping -c 1 -W 5 192.168.0.1 >/dev/null 2>&1 \ diff --git a/sync.sh b/sync.sh index e0f8eca..8cc6408 100755 --- a/sync.sh +++ b/sync.sh @@ -1,2 +1,2 @@ #!/bin/bash -rsync -avz --progress src/ andras@192.168.0.81:~/frame/ +rsync -avz --progress --exclude=.env src/ andras@192.168.0.81:~/frame/