Affichage paysage: canevas 1680x1264, rotation 90deg, layout 2 colonnes

This commit is contained in:
jerem
2026-06-15 14:33:05 +02:00
parent d75872c065
commit 56f71c0ea6
4 changed files with 112 additions and 83 deletions

View File

@@ -10,10 +10,12 @@ MONITORINK_CLAUDE_UA=claude-code/2.1.172
# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1 # Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1
MONITORINK_CCUSAGE=0 MONITORINK_CCUSAGE=0
# Affichage # Affichage — canevas de rendu en PAYSAGE ; le PNG est pivoté de 90° pour la Kobo.
MONITORINK_TZ=Europe/Paris MONITORINK_TZ=Europe/Paris
MONITORINK_WIDTH=1264 MONITORINK_WIDTH=1680
MONITORINK_HEIGHT=1680 MONITORINK_HEIGHT=1264
# Sens de rotation : "cw" = Kobo posée bouton à droite, "ccw" = bouton à gauche.
MONITORINK_ROTATE=cw
MONITORINK_CACHE_TTL=120 MONITORINK_CACHE_TTL=120
# Météo (Open-Meteo, sans clé) — coordonnées # Météo (Open-Meteo, sans clé) — coordonnées

View File

@@ -44,8 +44,12 @@ class Config:
# --- Affichage --- # --- Affichage ---
timezone: str = field(default_factory=lambda: _get("MONITORINK_TZ", "Europe/Paris")) timezone: str = field(default_factory=lambda: _get("MONITORINK_TZ", "Europe/Paris"))
locale: str = field(default_factory=lambda: _get("MONITORINK_LOCALE", "fr_FR")) locale: str = field(default_factory=lambda: _get("MONITORINK_LOCALE", "fr_FR"))
width: int = field(default_factory=lambda: int(_get("MONITORINK_WIDTH", "1264"))) # Canevas de RENDU en paysage (1680x1264). Le PNG est ensuite pivoté de 90° dans
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680"))) # render.py pour le panneau e-ink physiquement en portrait (1264x1680).
width: int = field(default_factory=lambda: int(_get("MONITORINK_WIDTH", "1680")))
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1264")))
# Sens de rotation pour l'affichage Kobo : "cw" (bouton à droite) ou "ccw".
rotate: str = field(default_factory=lambda: _get("MONITORINK_ROTATE", "cw").lower())
# --- Claude --- # --- Claude ---
# Chemin du fichier .credentials.json d'un login Claude ISOLÉ dédié à Monitorink # Chemin du fichier .credentials.json d'un login Claude ISOLÉ dédié à Monitorink

View File

@@ -1,4 +1,5 @@
"""Construit le contexte, rend le HTML puis le capture en PNG niveaux de gris (1264x1680).""" """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 from __future__ import annotations
import asyncio import asyncio
@@ -98,6 +99,10 @@ async def render_png() -> bytes:
# Conversion niveaux de gris (mode 'L') -> e-ink friendly, fichier plus léger. # Conversion niveaux de gris (mode 'L') -> e-ink friendly, fichier plus léger.
img = Image.open(io.BytesIO(png_bytes)).convert("L") 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
img = img.transpose(rota)
out = io.BytesIO() out = io.BytesIO()
img.save(out, format="PNG", optimize=True) img.save(out, format="PNG", optimize=True)
return out.getvalue() return out.getvalue()

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
/* Dashboard e-ink 1264x1680 — noir & blanc, fort contraste, pas de dépendance couleur. */ /* 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. */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
:root { :root {
--ink: #000; --ink: #000;
@@ -18,34 +19,49 @@
font-family: "DejaVu Sans", "Noto Sans", Arial, sans-serif; font-family: "DejaVu Sans", "Noto Sans", Arial, sans-serif;
-webkit-font-smoothing: none; -webkit-font-smoothing: none;
} }
body { padding: 48px 56px; display: flex; flex-direction: column; } /* Deux colonnes + pied de page pleine largeur. */
body {
padding: 44px 52px;
display: grid;
grid-template-columns: 600px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas: "left right" "footer footer";
column-gap: 56px;
row-gap: 28px;
}
.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 { margin-bottom: 40px; }
.rule { border: 0; border-top: 4px solid var(--line); margin: 0 0 28px; } .section:last-child { margin-bottom: 0; }
.rule { border: 0; border-top: 4px solid var(--line); margin: 0 0 32px; }
/* En-tête : heure + date */ /* En-tête : heure au-dessus, date dessous (alignées à gauche). */
header { display: flex; justify-content: space-between; align-items: flex-end; } header { display: flex; flex-direction: column; gap: 10px; }
.clock { font-size: 150px; font-weight: 800; line-height: 0.9; letter-spacing: -4px; } .clock { font-size: 210px; font-weight: 800; line-height: 0.82; letter-spacing: -6px; }
.date { font-size: 40px; font-weight: 600; text-align: right; } .date { font-size: 46px; font-weight: 600; }
.date .dow { font-size: 52px; font-weight: 800; text-transform: capitalize; } .date .dow { font-size: 68px; font-weight: 800; text-transform: capitalize; }
/* Météo */ /* Météo */
.weather { display: flex; align-items: center; gap: 36px; } .weather .top { display: flex; align-items: center; gap: 32px; }
.weather .icon { font-size: 110px; line-height: 1; } .weather .icon { font-size: 140px; line-height: 1; }
.weather .temp { font-size: 96px; font-weight: 800; } .weather .temp { font-size: 130px; font-weight: 800; line-height: 1; }
.weather .meta { font-size: 36px; color: var(--muted); } .weather .meta { font-size: 40px; color: var(--muted); margin-top: 18px; }
.weather .meta b { color: var(--ink); } .weather .meta b { color: var(--ink); }
/* Titre de section */ /* Titre de section */
.title { font-size: 34px; font-weight: 800; text-transform: uppercase; .title { font-size: 36px; font-weight: 800; text-transform: uppercase;
letter-spacing: 3px; margin-bottom: 22px; } letter-spacing: 3px; margin-bottom: 24px; }
/* Jauges Claude */ /* Jauges Claude */
.gauge { margin-bottom: 34px; } .gauge { margin-bottom: 36px; }
.gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } .gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.gauge .name { font-size: 40px; font-weight: 700; } .gauge .name { font-size: 42px; font-weight: 700; }
.gauge .pct { font-size: 64px; font-weight: 800; } .gauge .pct { font-size: 66px; font-weight: 800; }
.gauge .pct small { font-size: 32px; font-weight: 600; } .gauge .pct small { font-size: 32px; font-weight: 600; }
.bar { position: relative; height: 46px; background: var(--gauge-bg); .bar { position: relative; height: 50px; background: var(--gauge-bg);
border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; } border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; }
.bar .fill { position: absolute; top: 0; left: 0; bottom: 0; background: var(--ink); } .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); } .bar.low .fill { background: repeating-linear-gradient(45deg, #000 0 14px, #fff 14px 22px); }
@@ -53,84 +69,86 @@
.err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); } .err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); }
/* Grille Home Assistant */ /* Grille Home Assistant */
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 40px; } .ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; }
.ha-item { display: flex; justify-content: space-between; align-items: baseline; .ha-item { display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 3px solid var(--ink); padding-bottom: 12px; } border-bottom: 3px solid var(--ink); padding-bottom: 12px; }
.ha-item .k { font-size: 38px; font-weight: 600; } .ha-item .k { font-size: 38px; font-weight: 600; }
.ha-item .v { font-size: 44px; font-weight: 800; } .ha-item .v { font-size: 44px; font-weight: 800; }
footer { margin-top: auto; display: flex; justify-content: space-between; 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); } font-size: 28px; color: var(--muted); padding-top: 20px; border-top: 3px solid var(--ink); }
.stale { font-weight: 800; color: var(--ink); } .stale { font-weight: 800; color: var(--ink); }
</style> </style>
</head> </head>
<body> <body>
<header class="section"> <!-- Colonne gauche : heure, date, météo -->
<div class="clock">{{ time }}</div> <div class="col col-left">
<div class="date"> <header class="section">
<div class="dow">{{ dow }}</div> <div class="clock">{{ time }}</div>
<div>{{ date }}</div> <div class="date">
</div> <span class="dow">{{ dow }}</span> · {{ date }}
</header> </div>
<hr class="rule"> </header>
<hr class="rule">
<div class="section weather"> <div class="section weather">
{% if weather.ok %} {% if weather.ok %}
<div class="icon">{{ weather.icon }}</div> <div class="top">
<div> <div class="icon">{{ weather.icon }}</div>
<div class="temp">{{ weather.temp | round | int }}°</div> <div class="temp">{{ weather.temp | round | int }}°</div>
</div>
<div class="meta"> <div class="meta">
{{ weather.label }} · ressenti <b>{{ weather.feels_like | round | int }}°</b> {{ weather.label }} · ressenti <b>{{ weather.feels_like | round | int }}°</b>
</div> </div>
</div> <div class="meta">
<div class="meta" style="margin-left:auto; text-align:right;"> 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 %}
<div>min <b>{{ weather.temp_min | round | int }}°</b> / max <b>{{ weather.temp_max | round | int }}°</b></div>
{% if weather.precip_prob is not none %}<div>pluie <b>{{ weather.precip_prob }}%</b></div>{% endif %}
</div>
{% else %}
<div class="meta">Météo indisponible</div>
{% endif %}
</div>
<hr class="rule">
<div class="section">
<div class="title">Abonnement 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>
<div class="bar {% if g.remaining < 20 %}low{% endif %}"> {% else %}
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div> <div class="meta">Météo indisponible</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 %} {% 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 ha_states %}
<hr class="rule">
<div class="section">
<div class="title">Maison</div>
<div class="ha-grid">
{% for s in ha_states %}
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div>
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
<!-- Colonne droite : abonnement Claude + maison -->
<div class="col col-right">
<div class="section">
<div class="title">Abonnement 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 ha_states %}
<div class="section">
<div class="title">Maison</div>
<div class="ha-grid">
{% for s in ha_states %}
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<footer> <footer>
<span>Monitorink · 3 taps = redémarrer</span> <span>Monitorink · 3 taps = redémarrer</span>