132 lines
4.6 KiB
Python
132 lines
4.6 KiB
Python
"""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, 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, nas_status, codex_status, tracker_stats = await asyncio.gather(
|
|
claude_usage.fetch_usage(),
|
|
weather.fetch_weather(),
|
|
nas.fetch_status(),
|
|
codex.fetch_status(),
|
|
trackers.fetch_all(),
|
|
)
|
|
|
|
now = datetime.now(ZoneInfo(config.timezone))
|
|
trk_ts = trackers.last_updated()
|
|
trackers_updated = (
|
|
datetime.fromtimestamp(trk_ts, ZoneInfo(config.timezone)).strftime("%Hh%M")
|
|
if trk_ts else None
|
|
)
|
|
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),
|
|
"nas": nas_status,
|
|
"codex": codex_status,
|
|
"trackers": tracker_stats,
|
|
"trackers_updated": trackers_updated,
|
|
"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())
|