Claude clean up
All checks were successful
lint / ruff (push) Successful in 2m1s

This commit is contained in:
Andras Schmelczer 2026-05-03 11:06:19 +01:00
parent 7ba0c0376c
commit 88cda50037
11 changed files with 48 additions and 63 deletions

6
.gitignore vendored
View file

@ -1 +1,7 @@
*.pyc *.pyc
__pycache__/
.venv/
.ruff_cache/
*.egg-info/
.ipynb_checkpoints/
photo_history.json

View file

@ -31,7 +31,7 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
**`src/lib/immich.py`** — Immich API client. Key behaviors: **`src/lib/immich.py`** — Immich API client. Key behaviors:
- `_load_history()` / `_save_history()` track displayed photos in `photo_history.json` to avoid repeats (resets after 7 days). Asset is only marked displayed after a successful download. - `_load_history()` / `_save_history()` track displayed photos in `photo_history.json` to avoid repeats (resets after 7 days). Asset is only marked displayed after a successful download.
- `_pick_weighted_random()` biases selection: 20% favorites, 50% recently-added (last 30 days, by Immich `createdAt`), otherwise uniform random - `_pick_weighted_random()` picks a pool first, then a uniform random asset from it. Weights: on-this-day 0.30 (or 0.10 for the ±3-day fallback), favorites 0.18, recent-30-days 0.36, all 0.36. Empty pools are dropped before sampling.
- Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags. Raises if nothing matches the requested orientation. - Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags. Raises if nothing matches the requested orientation.
- Downloads preview-size thumbnails, not originals - Downloads preview-size thumbnails, not originals
- Asset lists (people-search and album) are cached on disk in `/tmp/frame_cache/` for 1 hour - Asset lists (people-search and album) are cached on disk in `/tmp/frame_cache/` for 1 hour
@ -40,10 +40,10 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
**`src/lib/homeassistant.py`** — Simple Home Assistant REST client for presence detection. **`src/lib/homeassistant.py`** — Simple Home Assistant REST client for presence detection.
**`src/lib/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline: **`src/lib/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline:
- Center-crops to 800x480 (or 480x800) - Falls back to a face-less center crop via `face_aware_crop` if the input isn't already at target size
- Enhances saturation/contrast/gamma for e-ink (caller passes values; CLI defaults live in `display.py`: saturation=1.3, contrast=1.05, gamma=0.90) - Enhances saturation/contrast/gamma for e-ink (caller passes values; CLI defaults live in `display.py`: saturation=1.3, contrast=1.05, gamma=0.90)
- Atkinson dithering to 6-color palette using numba JIT; produces palette indices directly (no Pillow quantize round-trip) - Atkinson dithering to 6-color palette using numba JIT; produces palette indices directly (no Pillow quantize round-trip)
- Packs into 4-bit-per-pixel buffer (two pixels per byte) via numpy - Packs into 4-bit-per-pixel buffer (two pixels per byte) and returns `bytes`
**`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18). **`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18).
@ -61,7 +61,7 @@ former `dither_test/`):
- **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this - **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this
- **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-opencv 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 environment variables**: `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN` (with hardcoded defaults in display.py)
- **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

View file

@ -1,12 +1,12 @@
## Installation ## Installation
sudo raspi-confi sudo raspi-config
- Enable SPI - Enable SPI
```sh ```sh
sudo apt update sudo apt update
sudo apt upgrade sudo apt upgrade
sudo apt install -y python3-pip python3-pil python3-opencv python3-smbus python3-numba sudo apt install -y python3-pip python3-pil python3-smbus python3-numba
sudo swapoff -a sudo swapoff -a
sudo systemctl mask swap.target sudo systemctl mask swap.target
sudo systemctl disable --now bluetooth sudo systemctl disable --now bluetooth
@ -14,7 +14,7 @@ sudo systemctl disable --now bluetooth
sudo nmcli c modify netplan-wlan0-HiddenPlace 802-11-wireless.powersave 2 sudo nmcli c modify netplan-wlan0-HiddenPlace 802-11-wireless.powersave 2
``` ```
Execute `sudo crontab -e` reland add Execute `sudo crontab -e` and add
``` ```
@reboot /usr/sbin/iw wlan0 set power_save off @reboot /usr/sbin/iw wlan0 set power_save off
*/5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1 */5 * * * * /home/andras/frame/wifi-check.sh >> /home/andras/wifi.log 2>&1

View file

@ -6,7 +6,6 @@ requires-python = ">=3.11,<3.14"
dependencies = [ dependencies = [
"numpy>=1.26", "numpy>=1.26",
"pillow>=10", "pillow>=10",
"opencv-python>=4.8",
"numba>=0.60", "numba>=0.60",
] ]

View file

@ -144,6 +144,9 @@ class ImmichClient:
return assets return assets
_ROTATED_ORIENTATIONS = {5, 6, 7, 8, "5", "6", "7", "8"}
def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]: def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
"""Keep assets matching the requested orientation. Skips assets without EXIF dimensions.""" """Keep assets matching the requested orientation. Skips assets without EXIF dimensions."""
out = [] out = []
@ -153,7 +156,7 @@ def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
h = exif.get("exifImageHeight") or 0 h = exif.get("exifImageHeight") or 0
if not (w and h): if not (w and h):
continue continue
if exif.get("orientation") in (6, 8, "6", "8"): if exif.get("orientation") in _ROTATED_ORIENTATIONS:
w, h = h, w w, h = h, w
if (h > w) == portrait: if (h > w) == portrait:
out.append(a) out.append(a)
@ -166,7 +169,7 @@ def _on_this_day_candidates(assets: list[dict]) -> tuple[list[dict], bool]:
Returns (candidates, is_exact). `is_exact` is True when same-month-day matches Returns (candidates, is_exact). `is_exact` is True when same-month-day matches
exist; callers use it to weight the pool higher than the looser ±3-day fallback. exist; callers use it to weight the pool higher than the looser ±3-day fallback.
""" """
today = datetime.now(UTC).date() today = datetime.now().date()
dated = [] dated = []
for a in assets: for a in assets:
exif = a.get("exifInfo") or {} exif = a.get("exifInfo") or {}

View file

@ -9,7 +9,8 @@ def urlopen_with_retry(req: Request, timeout: int = 30):
for delay in (3, 10, None): for delay in (3, 10, None):
try: try:
return urlopen(req, timeout=timeout) return urlopen(req, timeout=timeout)
except (URLError, TimeoutError): except (URLError, TimeoutError) as e:
if delay is None: if delay is None:
raise raise
print(f"urlopen {req.full_url}: {e}; retry in {delay}s")
time.sleep(delay) time.sleep(delay)

View file

@ -55,6 +55,7 @@ def format_age(asset: dict) -> str | None:
if count == 1: if count == 1:
return f"A {unit} ago" return f"A {unit} ago"
return f"{count} {unit}s ago" return f"{count} {unit}s ago"
return None
def format_location(asset: dict) -> str | None: def format_location(asset: dict) -> str | None:

View file

@ -1,11 +1,14 @@
"""Simple terminal progress bar for e-ink frame.""" """Simple terminal progress bar for e-ink frame."""
import sys
class ProgressBar: class ProgressBar:
def __init__(self, total: int, desc: str = ""): def __init__(self, total: int, desc: str = ""):
self.total = total self.total = total
self.desc = desc self.desc = desc
self._last_percent = -1 self._last_percent = -1
self._is_tty = sys.stdout.isatty()
def set(self, value: int) -> None: def set(self, value: int) -> None:
if self.total == 0: if self.total == 0:
@ -14,10 +17,16 @@ class ProgressBar:
percent = int(100 * value / self.total) percent = int(100 * value / self.total)
if percent == self._last_percent: if percent == self._last_percent:
return return
# In non-tty (cron log) mode, only emit a few milestones — no \r spam.
if not self._is_tty and percent not in (25, 50, 75, 100):
return
self._last_percent = percent self._last_percent = percent
prefix = f"{self.desc}: " if self.desc else ""
if self._is_tty:
filled = int(30 * value / self.total) filled = int(30 * value / self.total)
bar = "" * filled + "" * (30 - filled) bar = "" * filled + "" * (30 - filled)
end = "\n" if value >= self.total else "" end = "\n" if value >= self.total else ""
prefix = f"{self.desc}: " if self.desc else ""
print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True) print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True)
else:
print(f"{prefix}{percent}%", flush=True)

View file

@ -2,9 +2,9 @@
# Waveshare 7.3" 6-color e-Paper driver (modified) # Waveshare 7.3" 6-color e-Paper driver (modified)
import numpy as np import numpy as np
import cv2
from PIL import Image, ImageEnhance from PIL import Image, ImageEnhance
from numba import jit from numba import jit
from crop import face_aware_crop
from progress import ProgressBar from progress import ProgressBar
from overlay import render_text_into_indices from overlay import render_text_into_indices
from . import epdconfig from . import epdconfig
@ -13,13 +13,13 @@ EPD_WIDTH = 800
EPD_HEIGHT = 480 EPD_HEIGHT = 480
# 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors; # 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors;
# 4 is reserved/unused (filled with BLACK so nearest-color never picks it). # 4 is reserved/unused — _find_nearest_color skips it explicitly.
PALETTE_RGB = np.array([ PALETTE_RGB = np.array([
[0, 0, 0], # 0: BLACK [0, 0, 0], # 0: BLACK
[255, 255, 255], # 1: WHITE [255, 255, 255], # 1: WHITE
[255, 255, 0], # 2: YELLOW [255, 255, 0], # 2: YELLOW
[255, 0, 0], # 3: RED [255, 0, 0], # 3: RED
[0, 0, 0], # 4: unused [0, 0, 0], # 4: unused (skipped)
[0, 0, 255], # 5: BLUE [0, 0, 255], # 5: BLUE
[0, 255, 0], # 6: GREEN [0, 255, 0], # 6: GREEN
], dtype=np.float64) ], dtype=np.float64)
@ -56,28 +56,12 @@ def _enhance_for_eink(image: Image.Image, saturation: float,
return img return img
def _crop_center(image: Image.Image, target_w: int, target_h: int) -> Image.Image:
print("Center cropping...")
img_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
img_h, img_w = img_cv.shape[:2]
img_aspect, target_aspect = img_w / img_h, target_w / target_h
if img_aspect < target_aspect:
new_w, new_h = target_w, int(target_w / img_aspect)
else:
new_w, new_h = int(target_h * img_aspect), target_h
img_cv = cv2.resize(img_cv, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
x_off = (new_w - target_w) // 2
y_off = (new_h - target_h) // 2
cropped = img_cv[y_off:y_off + target_h, x_off:x_off + target_w]
return Image.fromarray(cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB))
@jit(nopython=True, cache=True) @jit(nopython=True, cache=True)
def _find_nearest_color(r, g, b, palette, weights): def _find_nearest_color(r, g, b, palette, weights):
best_idx, best_dist = 0, 1e10 best_idx, best_dist = 0, 1e10
for i in range(palette.shape[0]): for i in range(palette.shape[0]):
if i == 4: # reserved palette slot
continue
dr = (palette[i, 0] - r) * weights[0] dr = (palette[i, 0] - r) * weights[0]
dg = (palette[i, 1] - g) * weights[1] dg = (palette[i, 1] - g) * weights[1]
db = (palette[i, 2] - b) * weights[2] db = (palette[i, 2] - b) * weights[2]
@ -133,6 +117,8 @@ def _dither_atkinson(image: Image.Image) -> np.ndarray:
print("Dithering...") print("Dithering...")
progress = ProgressBar(height, desc="Dithering") progress = ProgressBar(height, desc="Dithering")
# Chunking is for progress reporting only; error diffusion still
# spans chunks because `img` is the same buffer between calls.
chunk_size = 48 chunk_size = 48
for i in range((height + chunk_size - 1) // chunk_size): for i in range((height + chunk_size - 1) // chunk_size):
start, end = i * chunk_size, min((i + 1) * chunk_size, height) start, end = i * chunk_size, min((i + 1) * chunk_size, height)
@ -208,7 +194,7 @@ class EPD:
image = image.convert('RGB') image = image.convert('RGB')
if image.size != (self.width, self.height): if image.size != (self.width, self.height):
print(f"Input: {image.size[0]}x{image.size[1]}{self.width}x{self.height}") print(f"Input: {image.size[0]}x{image.size[1]}{self.width}x{self.height}")
image = _crop_center(image, self.width, self.height) image = face_aware_crop(image, self.width, self.height, [])
print("Enhancing...") print("Enhancing...")
image = _enhance_for_eink(image, saturation, contrast, gamma) image = _enhance_for_eink(image, saturation, contrast, gamma)
@ -221,7 +207,7 @@ class EPD:
print("Packing buffer...") print("Packing buffer...")
flat = indices.reshape(-1) flat = indices.reshape(-1)
return ((flat[0::2].astype(np.uint8) << 4) | flat[1::2].astype(np.uint8)).tolist() return bytes((flat[0::2] << 4) | flat[1::2])
def display(self, image): def display(self, image):
self.send_command(0x10) self.send_command(0x10)

View file

@ -87,13 +87,13 @@ class RaspberryPi:
if pin == self.BUSY_PIN: if pin == self.BUSY_PIN:
return self.GPIO_BUSY_PIN.value return self.GPIO_BUSY_PIN.value
elif pin == self.RST_PIN: elif pin == self.RST_PIN:
return self.RST_PIN.value return self.GPIO_RST_PIN.value
elif pin == self.DC_PIN: elif pin == self.DC_PIN:
return self.DC_PIN.value return self.GPIO_DC_PIN.value
# elif pin == self.CS_PIN: # elif pin == self.CS_PIN:
# return self.CS_PIN.value # return self.GPIO_CS_PIN.value
elif pin == self.PWR_PIN: elif pin == self.PWR_PIN:
return self.PWR_PIN.value return self.GPIO_PWR_PIN.value
def delay_ms(self, delaytime): def delay_ms(self, delaytime):
time.sleep(delaytime / 1000.0) time.sleep(delaytime / 1000.0)
@ -134,7 +134,7 @@ class RaspberryPi:
self.DEV_SPI = CDLL(so_filename) self.DEV_SPI = CDLL(so_filename)
break break
if self.DEV_SPI is None: if self.DEV_SPI is None:
RuntimeError('Cannot find DEV_Config.so') raise RuntimeError('Cannot find DEV_Config.so')
self.DEV_SPI.DEV_Module_Init() self.DEV_SPI.DEV_Module_Init()

20
uv.lock generated
View file

@ -443,7 +443,6 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "numba" }, { name = "numba" },
{ name = "numpy" }, { name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" }, { name = "pillow" },
] ]
@ -461,7 +460,6 @@ notebook = [
requires-dist = [ requires-dist = [
{ name = "numba", specifier = ">=0.60" }, { name = "numba", specifier = ">=0.60" },
{ name = "numpy", specifier = ">=1.26" }, { name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=10" }, { name = "pillow", specifier = ">=10" },
] ]
@ -1212,24 +1210,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
] ]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
]
[[package]] [[package]]
name = "overrides" name = "overrides"
version = "7.7.0" version = "7.7.0"