Files
Monitorink/backend/render.py
jerem 6f7b2388f2 Dashboard: trackers en ledger 2 lignes, footer ancré, retrait Home Assistant
- Trackers: I/O + jetons fusionnés sur une ligne (↑↓), ratio en héros (40px)
- Footer: grid-rows minmax(0,1fr) + .pane overflow:hidden -> ne quitte plus le canevas
- Retire la section Maison (HA) du template + le fetch homeassistant de render.py
2026-06-17 11:40:52 +02:00

126 lines
4.4 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))
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,
"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())