Move secrets and URLs out of source into .env
All checks were successful
lint / ruff (push) Successful in 45s

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) <noreply@anthropic.com>
This commit is contained in:
Andras Schmelczer 2026-05-03 11:22:54 +01:00
parent 8609b4a884
commit 90b692da39
8 changed files with 69 additions and 19 deletions

4
.env.example Normal file
View file

@ -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

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ __pycache__/
*.egg-info/ *.egg-info/
.ipynb_checkpoints/ .ipynb_checkpoints/
photo_history.json photo_history.json
.env

View file

@ -62,7 +62,7 @@ former `dither_test/`):
- **Display refresh takes 12-15 seconds** — the BUSY pin polling handles this - **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 - **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` - **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 - **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 - **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 - `sys.path.append` is used to add `lib/` to the path from display.py

View file

@ -9,7 +9,6 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import os
import random import random
import sys import sys
import tempfile import tempfile
@ -21,8 +20,6 @@ REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook" CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook"
DEFAULT_PEOPLE = ("Me", "Ruby") DEFAULT_PEOPLE = ("Me", "Ruby")
DEFAULT_IMMICH_URL = "https://immich.example.com"
DEFAULT_IMMICH_API_KEY = "REDACTED_IMMICH_API_KEY"
def bootstrap() -> None: def bootstrap() -> None:
@ -32,15 +29,16 @@ def bootstrap() -> None:
if sp not in sys.path: if sp not in sys.path:
sys.path.insert(0, sp) sys.path.insert(0, sp)
sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig")) sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig"))
from env import load_env
load_env()
def immich_client(): def immich_client():
from env import require
from immich import ImmichClient from immich import ImmichClient
return ImmichClient( return ImmichClient(require("IMMICH_URL"), require("IMMICH_API_KEY"))
os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL),
os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY),
)
def is_landscape(asset: dict) -> bool: def is_landscape(asset: dict) -> bool:

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import fcntl import fcntl
import os
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -10,6 +9,7 @@ from PIL import Image
sys.path.append(str(Path(__file__).parent / "lib")) sys.path.append(str(Path(__file__).parent / "lib"))
from crop import face_aware_crop from crop import face_aware_crop
from env import load_env, require
from homeassistant import HomeAssistantClient from homeassistant import HomeAssistantClient
from immich import ImmichClient, get_random_photo_from_album, get_random_photo_of_people from immich import ImmichClient, get_random_photo_from_album, get_random_photo_of_people
from overlay import format_age, format_location 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 # GPIO pins at import time, so two overlapping invocations would both crash
# on "GPIO busy" before reaching the flock below. # on "GPIO busy" before reaching the flock below.
IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.example.com") load_env()
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY") IMMICH_URL = require("IMMICH_URL")
IMMICH_API_KEY = require("IMMICH_API_KEY")
HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com") HA_URL = require("HA_URL")
HA_TOKEN = os.environ.get( HA_TOKEN = require("HA_TOKEN")
"HA_TOKEN",
"REDACTED_HA_TOKEN",
)
HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"} HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"}

43
src/lib/env.py Normal file
View file

@ -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

View file

@ -4,7 +4,13 @@
# brcmfmac chip on the Pi Zero 2W. # brcmfmac chip on the Pi Zero 2W.
CONNECTION="netplan-wlan0-HiddenPlace" 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() { probe() {
ping -c 1 -W 5 192.168.0.1 >/dev/null 2>&1 \ ping -c 1 -W 5 192.168.0.1 >/dev/null 2>&1 \

View file

@ -1,2 +1,2 @@
#!/bin/bash #!/bin/bash
rsync -avz --progress src/ andras@192.168.0.81:~/frame/ rsync -avz --progress --exclude=.env src/ andras@192.168.0.81:~/frame/