"""Construit le contexte, rend le HTML en paysage (1680x1264) puis le capture en PNG niveaux de gris, pivoté de 90° pour le panneau e-ink portrait (1264x1680).""" from __future__ import annotations import asyncio import io from datetime import datetime, timezone from pathlib import Path from zoneinfo import ZoneInfo from jinja2 import Environment, FileSystemLoader, select_autoescape from PIL import Image from playwright.async_api import async_playwright from config import config from fonts import font_face_css from integrations import claude_usage, codex, homeassistant, kobo, nas, trackers, weather TEMPLATES = Path(__file__).parent / "templates" JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] MOIS = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", ] _env = Environment( loader=FileSystemLoader(str(TEMPLATES)), autoescape=select_autoescape(["html"]), ) def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]: """Transforme l'usage Claude en jauges affichables (la fonctionnalité phare).""" out: list[dict] = [] if usage.five_hour: out.append({ "name": "Session (5 h)", "remaining": usage.five_hour.remaining_pct, "resets_in": usage.five_hour.resets_in_human, "extra": "", }) if usage.seven_day: extra = "" if usage.seven_day_opus and usage.seven_day_opus.utilization: extra = f"Opus {usage.seven_day_opus.remaining_pct:.0f}% rest." out.append({ "name": "Hebdo (7 j)", "remaining": usage.seven_day.remaining_pct, "resets_in": usage.seven_day.resets_in_human, "extra": extra, }) return out async def build_context() -> dict: """Récupère toutes les sources en parallèle et assemble le contexte du template.""" usage, wx, ha, nas_status, codex_status, tracker_stats = await asyncio.gather( claude_usage.fetch_usage(), weather.fetch_weather(), homeassistant.fetch_states(), nas.fetch_status(), codex.fetch_status(), trackers.fetch_all(), ) now = datetime.now(ZoneInfo(config.timezone)) return { "width": config.width, "height": config.height, "fonts": font_face_css(), "time": now.strftime("%H:%M"), "dow": JOURS[now.weekday()], "date": f"{now.day} {MOIS[now.month - 1]}", "weather": wx, "claude": usage, "gauges": _gauges(usage), "ha_states": ha, "nas": nas_status, "codex": codex_status, "trackers": tracker_stats, "kobo": kobo.current(), "updated": now.strftime("%H:%M"), "stale": False, } def render_html(context: dict) -> str: return _env.get_template("dashboard.html").render(**context) async def render_image() -> Image.Image: """Rend le dashboard en image PIL niveaux de gris (mode 'L'), déjà pivotée pour le panneau e-ink portrait (1264x1680). Base commune au PNG et au diff de refresh partiel.""" context = await build_context() html = render_html(context) async with async_playwright() as p: browser = await p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"]) page = await browser.new_page( viewport={"width": config.width, "height": config.height}, device_scale_factor=1, ) await page.set_content(html, wait_until="networkidle") png_bytes = await page.screenshot(type="png", clip={ "x": 0, "y": 0, "width": config.width, "height": config.height, }) await browser.close() # Conversion niveaux de gris (mode 'L') -> e-ink friendly, fichier plus léger. img = Image.open(io.BytesIO(png_bytes)).convert("L") # Le canevas est rendu en paysage (1680x1264) ; on pivote de 90° pour le panneau # e-ink physiquement en portrait. "cw" = bouton à droite (rotation horaire). rota = Image.ROTATE_270 if config.rotate == "cw" else Image.ROTATE_90 return img.transpose(rota) def encode_png(img: Image.Image) -> bytes: """Encode une image PIL en PNG optimisé (helper partagé render_png / frame.py).""" out = io.BytesIO() img.save(out, format="PNG", optimize=True) return out.getvalue() async def render_png() -> bytes: """Rend le dashboard en PNG niveaux de gris prêt pour l'e-ink de la Kobo.""" return encode_png(await render_image())