Dashboard: refonte design « instrument 1-bit » (jauges graduées, polices vendorisées, glyphes météo)
- Identité noir & blanc pur (zéro gris, anti-ghosting e-ink) ; hachures pour conso/alarme - Typo vendorisée : Archivo (mots) + JetBrains Mono (nombres tabulaires), @font-face base64 - Jauge signature : noir = restant, repère seuil 20 %, hachures sous le seuil - Météo : glyphes 1-bit en silhouette (weather.kind) au lieu d'emoji couleur - Layout rééquilibré (plus de débordement), états dégradés soignés - dev/preview.py : aperçu hors-ligne du template
This commit is contained in:
37
backend/fonts.py
Normal file
37
backend/fonts.py
Normal file
@@ -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 <style> du template."""
|
||||
blocks = []
|
||||
for family, filename, weight in _FACES:
|
||||
data = (FONTS_DIR / filename).read_bytes()
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
blocks.append(
|
||||
"@font-face{font-family:'%s';font-style:normal;font-weight:%d;"
|
||||
"font-display:block;src:url(data:font/woff2;base64,%s) format('woff2');}"
|
||||
% (family, weight, b64)
|
||||
)
|
||||
return "\n".join(blocks)
|
||||
@@ -9,29 +9,30 @@ from config import config
|
||||
|
||||
API_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
# Codes WMO -> (libellé court FR, emoji). Suffisant pour un dashboard e-ink.
|
||||
# Codes WMO -> (libellé court FR, kind). `kind` pilote le glyphe 1-bit dessiné côté
|
||||
# template (clear/partly/cloudy/fog/rain/snow/storm) : pas d'emoji couleur sur e-ink.
|
||||
WMO = {
|
||||
0: ("Dégagé", "☀"),
|
||||
1: ("Peu nuageux", "🌤"),
|
||||
2: ("Nuageux", "⛅"),
|
||||
3: ("Couvert", "☁"),
|
||||
45: ("Brouillard", "🌫"),
|
||||
48: ("Brouillard givrant", "🌫"),
|
||||
51: ("Bruine légère", "🌦"),
|
||||
53: ("Bruine", "🌦"),
|
||||
55: ("Bruine forte", "🌦"),
|
||||
61: ("Pluie faible", "🌧"),
|
||||
63: ("Pluie", "🌧"),
|
||||
65: ("Pluie forte", "🌧"),
|
||||
71: ("Neige faible", "🌨"),
|
||||
73: ("Neige", "🌨"),
|
||||
75: ("Neige forte", "🌨"),
|
||||
80: ("Averses", "🌦"),
|
||||
81: ("Averses", "🌧"),
|
||||
82: ("Fortes averses", "⛈"),
|
||||
95: ("Orage", "⛈"),
|
||||
96: ("Orage + grêle", "⛈"),
|
||||
99: ("Orage + grêle", "⛈"),
|
||||
0: ("Dégagé", "clear"),
|
||||
1: ("Peu nuageux", "partly"),
|
||||
2: ("Nuageux", "partly"),
|
||||
3: ("Couvert", "cloudy"),
|
||||
45: ("Brouillard", "fog"),
|
||||
48: ("Brouillard givrant", "fog"),
|
||||
51: ("Bruine légère", "rain"),
|
||||
53: ("Bruine", "rain"),
|
||||
55: ("Bruine forte", "rain"),
|
||||
61: ("Pluie faible", "rain"),
|
||||
63: ("Pluie", "rain"),
|
||||
65: ("Pluie forte", "rain"),
|
||||
71: ("Neige faible", "snow"),
|
||||
73: ("Neige", "snow"),
|
||||
75: ("Neige forte", "snow"),
|
||||
80: ("Averses", "rain"),
|
||||
81: ("Averses", "rain"),
|
||||
82: ("Fortes averses", "storm"),
|
||||
95: ("Orage", "storm"),
|
||||
96: ("Orage + grêle", "storm"),
|
||||
99: ("Orage + grêle", "storm"),
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +43,7 @@ class Weather:
|
||||
temp: float | None = None
|
||||
feels_like: float | None = None
|
||||
label: str = ""
|
||||
icon: str = ""
|
||||
kind: str = ""
|
||||
temp_min: float | None = None
|
||||
temp_max: float | None = None
|
||||
precip_prob: int | None = None
|
||||
@@ -68,7 +69,7 @@ async def fetch_weather() -> Weather:
|
||||
cur = data.get("current", {})
|
||||
daily = data.get("daily", {})
|
||||
code = int(cur.get("weather_code", -1))
|
||||
label, icon = WMO.get(code, ("—", "·"))
|
||||
label, kind = WMO.get(code, ("—", "cloudy"))
|
||||
|
||||
def _first(key: str):
|
||||
vals = daily.get(key) or []
|
||||
@@ -79,7 +80,7 @@ async def fetch_weather() -> Weather:
|
||||
temp=cur.get("temperature_2m"),
|
||||
feels_like=cur.get("apparent_temperature"),
|
||||
label=label,
|
||||
icon=icon,
|
||||
kind=kind,
|
||||
temp_min=_first("temperature_2m_min"),
|
||||
temp_max=_first("temperature_2m_max"),
|
||||
precip_prob=_first("precipitation_probability_max"),
|
||||
|
||||
@@ -13,6 +13,7 @@ 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, weather
|
||||
|
||||
TEMPLATES = Path(__file__).parent / "templates"
|
||||
@@ -66,6 +67,7 @@ async def build_context() -> dict:
|
||||
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]}",
|
||||
|
||||
BIN
backend/static/fonts/archivo-700.woff2
Normal file
BIN
backend/static/fonts/archivo-700.woff2
Normal file
Binary file not shown.
BIN
backend/static/fonts/archivo-800.woff2
Normal file
BIN
backend/static/fonts/archivo-800.woff2
Normal file
Binary file not shown.
BIN
backend/static/fonts/jbmono-400.woff2
Normal file
BIN
backend/static/fonts/jbmono-400.woff2
Normal file
Binary file not shown.
BIN
backend/static/fonts/jbmono-500.woff2
Normal file
BIN
backend/static/fonts/jbmono-500.woff2
Normal file
Binary file not shown.
BIN
backend/static/fonts/jbmono-700.woff2
Normal file
BIN
backend/static/fonts/jbmono-700.woff2
Normal file
Binary file not shown.
BIN
backend/static/fonts/jbmono-800.woff2
Normal file
BIN
backend/static/fonts/jbmono-800.woff2
Normal file
Binary file not shown.
@@ -3,180 +3,243 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
/* Dashboard e-ink PAYSAGE 1680x1264 — noir & blanc, fort contraste, sans couleur.
|
||||
Le PNG est pivoté de 90° côté backend pour le panneau portrait de la Kobo. */
|
||||
/* ============================================================================
|
||||
MONITORINK — instrument de mesure 1-bit. Canevas PAYSAGE 1680x1264, pivoté 90°
|
||||
côté backend pour le panneau e-ink portrait de la Kobo.
|
||||
Règles e-ink : NOIR & BLANC purs, aucun gris (le gris fantôme au refresh partiel).
|
||||
La hiérarchie passe par taille/graisse ; le « consommé » par des hachures.
|
||||
Mots = Archivo (grotesque lourd) · Nombres mesurés = JetBrains Mono (tabulaire).
|
||||
========================================================================= */
|
||||
{{ fonts | safe }}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--ink: #000;
|
||||
--paper: #fff;
|
||||
--muted: #555;
|
||||
--line: #000;
|
||||
--gauge-bg: #d9d9d9;
|
||||
}
|
||||
:root { --ink: #000; --paper: #fff; }
|
||||
|
||||
html, body {
|
||||
width: {{ width }}px; height: {{ height }}px;
|
||||
background: var(--paper); color: var(--ink);
|
||||
font-family: "DejaVu Sans", "Noto Sans", Arial, sans-serif;
|
||||
font-family: "Archivo", "DejaVu Sans", sans-serif;
|
||||
-webkit-font-smoothing: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Deux colonnes + pied de page pleine largeur. */
|
||||
.num { font-family: "JetBrains Mono", monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
body {
|
||||
padding: 44px 52px;
|
||||
padding: 46px 52px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 600px 1fr;
|
||||
grid-template-columns: 560px 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: "left right" "footer footer";
|
||||
column-gap: 56px;
|
||||
row-gap: 28px;
|
||||
grid-template-areas: "left right" "foot foot";
|
||||
column-gap: 52px;
|
||||
}
|
||||
.col {
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
}
|
||||
.col-left { grid-area: left; }
|
||||
.col-right { grid-area: right; padding-left: 56px; border-left: 5px solid var(--line); }
|
||||
.section { margin-bottom: 40px; }
|
||||
.section:last-child { margin-bottom: 0; }
|
||||
.rule { border: 0; border-top: 4px solid var(--line); margin: 0 0 32px; }
|
||||
.pane { grid-area: left; display: flex; flex-direction: column; min-width: 0; }
|
||||
.deck { grid-area: right; display: flex; flex-direction: column; min-width: 0;
|
||||
padding-left: 52px; border-left: 4px solid var(--ink); }
|
||||
|
||||
/* Météo */
|
||||
.weather .top { display: flex; align-items: center; gap: 32px; }
|
||||
.weather .icon { font-size: 140px; line-height: 1; }
|
||||
.weather .temp { font-size: 130px; font-weight: 800; line-height: 1; }
|
||||
.weather .meta { font-size: 40px; color: var(--muted); margin-top: 18px; }
|
||||
.weather .meta b { color: var(--ink); }
|
||||
/* Étiquette de bloc : barre pleine + mot. Encode "section", pas de déco capitale flottante. */
|
||||
.label { display: flex; align-items: center; gap: 16px; margin-bottom: 26px; }
|
||||
.label::before { content: ""; width: 26px; height: 26px; background: var(--ink); flex: 0 0 auto; }
|
||||
.label .t { font-weight: 800; font-size: 33px; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.label .meta { font-weight: 700; font-size: 24px; letter-spacing: 1px; text-transform: uppercase;
|
||||
margin-left: auto; padding: 4px 12px; border: 3px solid var(--ink); }
|
||||
.label .alarm { background: var(--ink); color: var(--paper); }
|
||||
|
||||
/* Titre de section */
|
||||
.title { font-size: 36px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 3px; margin-bottom: 24px; }
|
||||
hr.div { border: 0; border-top: 4px solid var(--ink); margin: 30px 0; }
|
||||
|
||||
/* Jauges Claude */
|
||||
.gauge { margin-bottom: 36px; }
|
||||
.gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.gauge .name { font-size: 42px; font-weight: 700; }
|
||||
.gauge .pct { font-size: 66px; font-weight: 800; }
|
||||
.gauge .pct small { font-size: 32px; font-weight: 600; }
|
||||
.bar { position: relative; height: 50px; background: var(--gauge-bg);
|
||||
border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; }
|
||||
.bar .fill { position: absolute; top: 0; left: 0; bottom: 0; background: var(--ink); }
|
||||
.bar.low .fill { background: repeating-linear-gradient(45deg, #000 0 14px, #fff 14px 22px); }
|
||||
.gauge .sub { font-size: 30px; color: var(--muted); margin-top: 10px; }
|
||||
.err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); }
|
||||
/* ---- Météo : silhouette 1-bit + relevé mono ---- */
|
||||
.wx { display: flex; align-items: center; gap: 30px; }
|
||||
.wx svg { width: 150px; height: 150px; flex: 0 0 auto; }
|
||||
.wx .temp { font-size: 144px; font-weight: 800; line-height: .82; }
|
||||
.wx .deg { font-size: 64px; vertical-align: top; }
|
||||
.wx-meta { margin-top: 18px; font-size: 30px; line-height: 1.45; }
|
||||
.wx-meta .k { font-family: "Archivo", sans-serif; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 1px; font-size: 24px; }
|
||||
|
||||
/* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */
|
||||
.nas-list { display: flex; flex-direction: column; gap: 18px; }
|
||||
.nas-list .ha-item { border-bottom: 0; padding-bottom: 0; }
|
||||
.bad { font-weight: 800; }
|
||||
/* ---- Lignes de données (NAS, Maison) ---- */
|
||||
.rows { display: flex; flex-direction: column; }
|
||||
.rows.grid2 { display: grid; grid-template-columns: 1fr 1fr; column-gap: 40px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
|
||||
padding: 13px 0; border-bottom: 2px solid var(--ink); }
|
||||
.row .k { font-weight: 700; font-size: 31px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row .v { font-size: 36px; font-weight: 700; white-space: nowrap; }
|
||||
.rows.grid2 .k { font-size: 28px; } .rows.grid2 .v { font-size: 32px; }
|
||||
.ko { display: inline-block; padding: 0 8px; background: var(--ink); color: var(--paper); }
|
||||
|
||||
/* Grille Home Assistant */
|
||||
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; }
|
||||
.ha-item { display: flex; justify-content: space-between; align-items: baseline;
|
||||
border-bottom: 3px solid var(--ink); padding-bottom: 12px; }
|
||||
.ha-item .k { font-size: 38px; font-weight: 600; }
|
||||
.ha-item .v { font-size: 44px; font-weight: 800; }
|
||||
/* ============================ JAUGE (signature) ============================
|
||||
Échelle graduée : noir plein = budget RESTANT (le poids visuel colle au "% restant").
|
||||
Repère ▼ = seuil d'alerte 20 %, toujours lisible (au-dessus de la piste).
|
||||
Sous le seuil : zone consommée en hachures + état alarme. */
|
||||
.g { margin-bottom: 34px; }
|
||||
.g:last-child { margin-bottom: 0; }
|
||||
.g .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
|
||||
.g .name { font-weight: 700; font-size: 38px; }
|
||||
.g .pct { font-size: 80px; font-weight: 800; line-height: .8; }
|
||||
.g .pct .u { font-size: 24px; font-weight: 700; letter-spacing: .5px; }
|
||||
|
||||
footer { grid-area: footer; display: flex; justify-content: space-between;
|
||||
font-size: 28px; color: var(--muted); padding-top: 20px; border-top: 3px solid var(--ink); }
|
||||
.stale { font-weight: 800; color: var(--ink); }
|
||||
.meter { position: relative; padding-top: 26px; } /* place pour le repère de seuil */
|
||||
.meter .mark { position: absolute; top: 0; left: 20%; transform: translateX(-50%);
|
||||
width: 0; height: 0; border-left: 11px solid transparent;
|
||||
border-right: 11px solid transparent; border-top: 14px solid var(--ink); }
|
||||
.track { position: relative; height: 58px; border: 4px solid var(--ink); background: var(--paper);
|
||||
overflow: hidden; }
|
||||
.track .fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--ink); }
|
||||
.track .tick { position: absolute; top: 0; width: 3px; height: 16px; background: var(--ink); }
|
||||
.track .t25 { left: 25%; } .track .t50 { left: 50%; } .track .t75 { left: 75%; }
|
||||
.g.low .track { background: repeating-linear-gradient(-45deg, #000 0 4px, #fff 4px 11px); }
|
||||
.g.low .mark { border-top-width: 18px; border-left-width: 13px; border-right-width: 13px; }
|
||||
|
||||
.g .sub { font-size: 28px; margin-top: 12px; }
|
||||
|
||||
/* Variante compacte (Codex, secondaire). */
|
||||
.g.mini { margin-bottom: 22px; }
|
||||
.g.mini .name { font-size: 30px; }
|
||||
.g.mini .pct { font-size: 52px; }
|
||||
.g.mini .pct .u { font-size: 22px; }
|
||||
.g.mini .meter { padding-top: 22px; }
|
||||
.g.mini .track { height: 38px; }
|
||||
.g.mini .sub { font-size: 24px; margin-top: 9px; }
|
||||
|
||||
.err { font-size: 36px; font-weight: 700; padding: 22px; border: 4px solid var(--ink);
|
||||
background: repeating-linear-gradient(-45deg, #000 0 4px, #fff 4px 11px); }
|
||||
.err span { background: var(--paper); padding: 6px 10px; }
|
||||
|
||||
/* ---- Pied de page ---- */
|
||||
footer { grid-area: foot; display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: 26px; padding: 18px 0 22px; border-top: 4px solid var(--ink);
|
||||
font-size: 26px; }
|
||||
footer .num { font-weight: 500; }
|
||||
.stale { background: var(--ink); color: var(--paper); padding: 2px 10px; font-weight: 700; }
|
||||
|
||||
.space { flex: 1 1 auto; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
{# ---- Glyphes météo 1-bit (silhouettes pleines, nettes sur e-ink) ---- #}
|
||||
{% macro sun(cx, cy, r) -%}
|
||||
<circle cx="{{cx}}" cy="{{cy}}" r="{{r}}"/>
|
||||
{% for a in [0,45,90,135,180,225,270,315] %}
|
||||
<rect x="{{cx-2.5}}" y="{{cy-r-13}}" width="5" height="11" transform="rotate({{a}} {{cx}} {{cy}})"/>
|
||||
{% endfor %}
|
||||
{%- endmacro %}
|
||||
{% macro cloud(ox, oy, s) -%}
|
||||
<g transform="translate({{ox}},{{oy}}) scale({{s}})">
|
||||
<circle cx="32" cy="42" r="20"/><circle cx="56" cy="34" r="26"/>
|
||||
<circle cx="78" cy="44" r="18"/><rect x="30" y="42" width="50" height="22" rx="11"/>
|
||||
</g>
|
||||
{%- endmacro %}
|
||||
{% macro wxicon(kind) -%}
|
||||
<svg viewBox="0 0 100 100" fill="#000" stroke="#000" stroke-linecap="round">
|
||||
{% 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] %}<rect x="14" y="{{y}}" width="72" height="5" rx="2"/>{% endfor %}
|
||||
{% elif kind == 'rain' %}{{ cloud(8, 8, 0.92) }}
|
||||
{% for x in [30, 50, 70] %}<rect x="{{x}}" y="74" width="5" height="20" rx="2" transform="rotate(18 {{x}} 84)"/>{% endfor %}
|
||||
{% elif kind == 'snow' %}{{ cloud(8, 8, 0.92) }}
|
||||
{% for x in [30, 50, 70] %}<circle cx="{{x}}" cy="86" r="5"/>{% endfor %}
|
||||
{% elif kind == 'storm' %}{{ cloud(8, 4, 0.92) }}
|
||||
<polygon points="52,66 38,90 50,90 44,100 66,76 53,76 60,66" stroke="none"/>
|
||||
{% else %}{{ cloud(8, 22, 0.95) }}{% endif %}
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{# ---- Jauge : la signature ---- #}
|
||||
{% macro gauge(g, mini=False) -%}
|
||||
<div class="g{% if g.remaining < 20 %} low{% endif %}{% if mini %} mini{% endif %}">
|
||||
<div class="head">
|
||||
<span class="name">{{ g.name }}</span>
|
||||
<span class="pct num">{{ g.remaining | round | int }}<span class="u">% restant</span></span>
|
||||
</div>
|
||||
<div class="meter">
|
||||
<span class="mark"></span>
|
||||
<div class="track">
|
||||
<span class="tick t25"></span><span class="tick t50"></span><span class="tick t75"></span>
|
||||
<div class="fill" style="width: {{ g.remaining | round(1) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub num">reset {{ g.resets_in }} · {{ (100 - g.remaining) | round | int }}% conso{% if g.extra %} · {{ g.extra }}{% endif %}</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Colonne gauche : météo, NAS -->
|
||||
<div class="col col-left">
|
||||
<div class="section weather">
|
||||
<!-- COLONNE GAUCHE : météo + infra maison -->
|
||||
<div class="pane">
|
||||
<div class="wx">
|
||||
{% if weather.ok %}
|
||||
<div class="top">
|
||||
<div class="icon">{{ weather.icon }}</div>
|
||||
<div class="temp">{{ weather.temp | round | int }}°</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{{ weather.label }} · ressenti <b>{{ weather.feels_like | round | int }}°</b>
|
||||
</div>
|
||||
<div class="meta">
|
||||
min <b>{{ weather.temp_min | round | int }}°</b> / max <b>{{ weather.temp_max | round | int }}°</b>{% if weather.precip_prob is not none %} · pluie <b>{{ weather.precip_prob }}%</b>{% endif %}
|
||||
{{ wxicon(weather.kind) }}
|
||||
<div>
|
||||
<div class="temp num">{{ weather.temp | round | int }}<span class="deg">°C</span></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="meta">Météo indisponible</div>
|
||||
<div class="wx-meta">Météo indisponible</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if weather.ok %}
|
||||
<div class="wx-meta num">
|
||||
{{ weather.label }} · ressenti <span class="k">{{ weather.feels_like | round | int }}°</span>
|
||||
· {{ weather.temp_min | round | int }}° / {{ weather.temp_max | round | int }}°{% if weather.precip_prob is not none %} · pluie {{ weather.precip_prob }}%{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nas.ok %}
|
||||
<hr class="rule">
|
||||
<div class="section">
|
||||
<div class="title">NAS</div>
|
||||
<div class="nas-list">
|
||||
<hr class="div">
|
||||
<div class="label"><span class="t">NAS</span>
|
||||
{% if nas.docker_unhealthy %}<span class="meta alarm">{{ nas.docker_unhealthy }} KO</span>{% endif %}</div>
|
||||
<div class="rows">
|
||||
{% for d in nas.disks %}
|
||||
<div class="ha-item"><span class="k">{{ d.label }}</span><span class="v">{{ d.percent | round | int }}% · {{ d.free_human }} libre</span></div>
|
||||
{% endfor %}
|
||||
<div class="ha-item"><span class="k">Docker</span><span class="v">{{ nas.docker_running }}/{{ nas.docker_total }}{% if nas.docker_unhealthy %} · <span class="bad">{{ nas.docker_unhealthy }} KO</span>{% else %} ✓{% endif %}</span></div>
|
||||
<div class="ha-item"><span class="k">Port VPN</span><span class="v">{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}<span class="bad">✗ désync</span>{% endif %}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite : abonnement Claude + maison -->
|
||||
<div class="col col-right">
|
||||
<div class="section">
|
||||
<div class="title">Claude{% if claude.ok %} · Max 5x{% endif %}</div>
|
||||
{% if not claude.ok %}
|
||||
<div class="err">⚠ {{ claude.error }}</div>
|
||||
{% else %}
|
||||
{% for g in gauges %}
|
||||
<div class="gauge">
|
||||
<div class="row">
|
||||
<span class="name">{{ g.name }}</span>
|
||||
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
|
||||
</div>
|
||||
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
|
||||
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div>
|
||||
</div>
|
||||
<div class="sub">{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}{% if g.extra %} · {{ g.extra }}{% endif %}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if claude.extra %}
|
||||
<div class="sub" style="font-size:32px;">{{ claude.extra.label }}</div>
|
||||
{% endif %}
|
||||
{% if claude.burn_rate %}
|
||||
<div class="sub" style="font-size:32px;">Burn rate : {{ claude.burn_rate | round | int }} tok/min</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if codex.ok %}
|
||||
<div class="section">
|
||||
<div class="title">Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · <span class="bad">⚠ limite atteinte</span>{% endif %}</div>
|
||||
{% for g in codex.gauges %}
|
||||
<div class="gauge">
|
||||
<div class="row">
|
||||
<span class="name">{{ g.name }}</span>
|
||||
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
|
||||
</div>
|
||||
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
|
||||
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div>
|
||||
</div>
|
||||
<div class="sub">{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}</div>
|
||||
</div>
|
||||
<div class="row"><span class="k">{{ d.label }}</span><span class="v num">{{ d.percent | round | int }}% · {{ d.free_human }}</span></div>
|
||||
{% endfor %}
|
||||
<div class="row"><span class="k">Docker</span><span class="v num">{{ nas.docker_running }}/{{ nas.docker_total }}{% if not nas.docker_unhealthy %} ✓{% endif %}</span></div>
|
||||
<div class="row"><span class="k">VPN</span><span class="v num">{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}<span class="ko">DÉSYNC</span>{% endif %}</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ha_states %}
|
||||
<div class="section">
|
||||
<div class="title">Maison</div>
|
||||
<div class="ha-grid">
|
||||
<hr class="div">
|
||||
<div class="label"><span class="t">Maison</span></div>
|
||||
<div class="rows grid2">
|
||||
{% for s in ha_states %}
|
||||
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div>
|
||||
<div class="row"><span class="k">{{ s.label }}</span><span class="v num">{{ s.display }}</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="space"></div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNE DROITE : budgets (le héros) -->
|
||||
<div class="deck">
|
||||
<div class="label"><span class="t">Claude</span>
|
||||
{% if claude.ok %}<span class="meta">Max 5×</span>{% endif %}</div>
|
||||
{% if not claude.ok %}
|
||||
<div class="err"><span>⚠ {{ claude.error }}</span></div>
|
||||
{% else %}
|
||||
{% for g in gauges %}{{ gauge(g) }}{% endfor %}
|
||||
{% if claude.extra or claude.burn_rate %}
|
||||
<div class="sub num" style="font-size:26px; margin-top:6px;">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if codex.ok %}
|
||||
<hr class="div">
|
||||
<div class="label"><span class="t">Codex</span>
|
||||
{% if codex.plan_type %}<span class="meta">{{ codex.plan_type | capitalize }}</span>{% endif %}
|
||||
{% if codex.limited %}<span class="meta alarm">Limite</span>{% endif %}</div>
|
||||
{% for g in codex.gauges %}{{ gauge(g, mini=True) }}{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="space"></div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span>Monitorink · 3 appuis bouton page = redémarrer</span>
|
||||
{% if kobo.ok %}<span class="{% if kobo.low %}stale{% endif %}">{% if kobo.charging %}⚡{% else %}🔋{% endif %} Kobo {{ kobo.percent }}%{% if kobo.stale %} · ?{% endif %}</span>{% endif %}
|
||||
<span class="{% if stale %}stale{% endif %}">maj {{ updated }}{% if stale %} · DONNÉE PÉRIMÉE{% endif %}</span>
|
||||
<span class="num">monitorink · 3× bouton page → reboot</span>
|
||||
<span class="space"></span>
|
||||
{% if kobo.ok %}<span class="num">{% if kobo.charging %}⚡{% else %}batt{% endif %} {{ kobo.percent }}%{% if kobo.stale %} ?{% endif %}</span>{% endif %}
|
||||
<span class="num" style="margin-left:28px;">maj {{ updated }}{% if stale %} <span class="stale">PÉRIMÉ</span>{% endif %}</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
93
dev/preview.py
Normal file
93
dev/preview.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user