diff --git a/backend/fonts.py b/backend/fonts.py new file mode 100644 index 0000000..b162639 --- /dev/null +++ b/backend/fonts.py @@ -0,0 +1,37 @@ +"""Génère le bloc CSS @font-face avec les woff2 vendorisés embarqués en data-URI. + +Playwright rend via page.set_content() (pas de base URL) : les chemins de police +relatifs ne se résolvent pas. On embarque donc les woff2 en base64 directement dans +le CSS. Résultat mémoïsé (les fichiers ne changent pas au runtime).""" +from __future__ import annotations + +import base64 +from functools import lru_cache +from pathlib import Path + +FONTS_DIR = Path(__file__).parent / "static" / "fonts" + +# (famille CSS, fichier, graisse) +_FACES = [ + ("Archivo", "archivo-700.woff2", 700), + ("Archivo", "archivo-800.woff2", 800), + ("JetBrains Mono", "jbmono-400.woff2", 400), + ("JetBrains Mono", "jbmono-500.woff2", 500), + ("JetBrains Mono", "jbmono-700.woff2", 700), + ("JetBrains Mono", "jbmono-800.woff2", 800), +] + + +@lru_cache(maxsize=1) +def font_face_css() -> str: + """CSS @font-face complet (data-URI) à injecter dans le + +{# ---- Glyphes météo 1-bit (silhouettes pleines, nettes sur e-ink) ---- #} +{% macro sun(cx, cy, r) -%} + + {% for a in [0,45,90,135,180,225,270,315] %} + + {% endfor %} +{%- endmacro %} +{% macro cloud(ox, oy, s) -%} + + + + +{%- endmacro %} +{% macro wxicon(kind) -%} + + {% if kind == 'clear' %}{{ sun(50, 50, 21) }} + {% elif kind == 'partly' %}{{ sun(34, 32, 15) }}{{ cloud(18, 36, 0.78) }} + {% elif kind == 'cloudy' %}{{ cloud(8, 22, 0.95) }} + {% elif kind == 'fog' %}{{ cloud(8, 12, 0.9) }} + {% for y in [76, 86, 96] %}{% endfor %} + {% elif kind == 'rain' %}{{ cloud(8, 8, 0.92) }} + {% for x in [30, 50, 70] %}{% endfor %} + {% elif kind == 'snow' %}{{ cloud(8, 8, 0.92) }} + {% for x in [30, 50, 70] %}{% endfor %} + {% elif kind == 'storm' %}{{ cloud(8, 4, 0.92) }} + + {% else %}{{ cloud(8, 22, 0.95) }}{% endif %} + +{%- endmacro %} + +{# ---- Jauge : la signature ---- #} +{% macro gauge(g, mini=False) -%} +
+
+ {{ g.name }} + {{ g.remaining | round | int }}% restant +
+
+ +
+ +
+
+
+
reset {{ g.resets_in }} · {{ (100 - g.remaining) | round | int }}% conso{% if g.extra %} · {{ g.extra }}{% endif %}
+
+{%- endmacro %} + - -
-
+ +
+
{% if weather.ok %} -
-
{{ weather.icon }}
-
{{ weather.temp | round | int }}°
-
-
- {{ weather.label }} · ressenti {{ weather.feels_like | round | int }}° -
-
- min {{ weather.temp_min | round | int }}° / max {{ weather.temp_max | round | int }}°{% if weather.precip_prob is not none %} · pluie {{ weather.precip_prob }}%{% endif %} + {{ wxicon(weather.kind) }} +
+
{{ weather.temp | round | int }}°C
{% else %} -
Météo indisponible
+
Météo indisponible
{% endif %}
- - {% if nas.ok %} -
-
-
NAS
-
- {% for d in nas.disks %} -
{{ d.label }}{{ d.percent | round | int }}% · {{ d.free_human }} libre
- {% endfor %} -
Docker{{ nas.docker_running }}/{{ nas.docker_total }}{% if nas.docker_unhealthy %} · {{ nas.docker_unhealthy }} KO{% else %} ✓{% endif %}
-
Port VPN{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}✗ désync{% endif %}
-
+ {% if weather.ok %} +
+ {{ weather.label }} · ressenti {{ weather.feels_like | round | int }}° + · {{ weather.temp_min | round | int }}° / {{ weather.temp_max | round | int }}°{% if weather.precip_prob is not none %} · pluie {{ weather.precip_prob }}%{% endif %}
{% endif %} -
- -
-
-
Claude{% if claude.ok %} · Max 5x{% endif %}
- {% if not claude.ok %} -
⚠ {{ claude.error }}
- {% else %} - {% for g in gauges %} -
-
- {{ g.name }} - {{ g.remaining | round | int }}% restant -
-
-
-
-
{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}{% if g.extra %} · {{ g.extra }}{% endif %}
-
- {% endfor %} - {% if claude.extra %} -
{{ claude.extra.label }}
- {% endif %} - {% if claude.burn_rate %} -
Burn rate : {{ claude.burn_rate | round | int }} tok/min
- {% endif %} - {% endif %} -
- - {% if codex.ok %} -
-
Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · ⚠ limite atteinte{% endif %}
- {% for g in codex.gauges %} -
-
- {{ g.name }} - {{ g.remaining | round | int }}% restant -
-
-
-
-
{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}
-
+ {% if nas.ok %} +
+
NAS + {% if nas.docker_unhealthy %}{{ nas.docker_unhealthy }} KO{% endif %}
+
+ {% for d in nas.disks %} +
{{ d.label }}{{ d.percent | round | int }}% · {{ d.free_human }}
{% endfor %} +
Docker{{ nas.docker_running }}/{{ nas.docker_total }}{% if not nas.docker_unhealthy %} ✓{% endif %}
+
VPN{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}DÉSYNC{% endif %}
{% endif %} {% if ha_states %} -
-
Maison
-
- {% for s in ha_states %} -
{{ s.label }}{{ s.display }}
- {% endfor %} -
+
+
Maison
+
+ {% for s in ha_states %} +
{{ s.label }}{{ s.display }}
+ {% endfor %}
{% endif %} + +
+
+ + +
+
Claude + {% if claude.ok %}Max 5×{% endif %}
+ {% if not claude.ok %} +
⚠ {{ claude.error }}
+ {% else %} + {% for g in gauges %}{{ gauge(g) }}{% endfor %} + {% if claude.extra or claude.burn_rate %} +
+ {% if claude.extra %}{{ claude.extra.label }}{% endif %}{% if claude.extra and claude.burn_rate %} · {% endif %}{% if claude.burn_rate %}burn {{ claude.burn_rate | round | int }} tok/min{% endif %} +
+ {% endif %} + {% endif %} + + {% if codex.ok %} +
+
Codex + {% if codex.plan_type %}{{ codex.plan_type | capitalize }}{% endif %} + {% if codex.limited %}Limite{% endif %}
+ {% for g in codex.gauges %}{{ gauge(g, mini=True) }}{% endfor %} + {% endif %} + +
diff --git a/dev/preview.py b/dev/preview.py new file mode 100644 index 0000000..b3a13bd --- /dev/null +++ b/dev/preview.py @@ -0,0 +1,93 @@ +"""Aperçu design hors-ligne : rend dashboard.html avec des données fictives en PNG +paysage (1680x1264, niveaux de gris, sans rotation), pour itérer sur le design sans +dépendre des intégrations réseau (Claude, météo, NAS, HA). + +Usage: .venv/bin/python ../dev/preview.py [sortie.png] +""" +from __future__ import annotations + +import asyncio +import io +import sys +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from PIL import Image +from playwright.async_api import async_playwright + +BACKEND = Path(__file__).resolve().parent.parent / "backend" +TEMPLATES = BACKEND / "templates" +WIDTH, HEIGHT = 1680, 1264 + +sys.path.insert(0, str(BACKEND)) +from fonts import font_face_css # noqa: E402 + +env = Environment( + loader=FileSystemLoader(str(TEMPLATES)), + autoescape=select_autoescape(["html"]), +) + +CTX = { + "width": WIDTH, + "height": HEIGHT, + "fonts": font_face_css(), + "time": "14:32", + "dow": "lundi", + "date": "15 juin", + "updated": "14:32", + "stale": False, + "weather": { + "ok": True, "kind": "partly", "temp": 21.4, "feels_like": 20.1, + "temp_min": 14.0, "temp_max": 24.0, "precip_prob": 20, + "label": "Peu nuageux", + }, + "claude": { + "ok": True, + "extra": {"label": "Crédits API : 38 % restant"}, + "burn_rate": 1820, + }, + "gauges": [ + {"name": "Session (5 h)", "remaining": 64, "resets_in": "2 h 10", "extra": ""}, + {"name": "Hebdo (7 j)", "remaining": 12, "resets_in": "3 j", "extra": "Opus 41% rest."}, + ], + "codex": { + "ok": True, "plan_type": "plus", "limited": False, + "gauges": [ + {"name": "Session (5 h)", "remaining": 78, "resets_in": "1 h 40"}, + {"name": "Hebdo (7 j)", "remaining": 33, "resets_in": "4 j"}, + ], + }, + "nas": { + "ok": True, + "disks": [ + {"label": "Volume 1", "percent": 72, "free_human": "1,4 To"}, + {"label": "Volume 2", "percent": 41, "free_human": "5,8 To"}, + ], + "docker_running": 18, "docker_total": 19, "docker_unhealthy": 1, + "vpn_ok": True, "vpn_port": 51820, + }, + "ha_states": [ + {"label": "Salon", "display": "21°"}, + {"label": "Chambre", "display": "19°"}, + {"label": "Humidité", "display": "48 %"}, + {"label": "Porte garage", "display": "Fermée"}, + ], + "kobo": {"ok": True, "percent": 63, "charging": False, "low": False, "stale": False}, +} + + +async def main() -> None: + out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "preview.png" + html = env.get_template("dashboard.html").render(**CTX) + 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": WIDTH, "height": HEIGHT}, device_scale_factor=1) + await page.set_content(html, wait_until="networkidle") + png = await page.screenshot(type="png", clip={"x": 0, "y": 0, "width": WIDTH, "height": HEIGHT}) + await browser.close() + Image.open(io.BytesIO(png)).convert("L").save(out) + print(f"écrit {out}") + + +if __name__ == "__main__": + asyncio.run(main())