Refresh partiel e-ink : ne redessine que la zone changée, full refresh ~1h

Backend : endpoints /frame.meta (ligne 'MODE X Y W H SEQ') + /frame.png qui
servent un crop de la zone modifiée (diff PIL par client) ou l'image pleine.
Full refresh forcé tous les N cycles (MONITORINK_FULL_EVERY=12, ~1h) ou si la
zone change sur plus de 60% de l'écran. Mode 'noop' quand rien ne change.

Anti-429 : l'usage Claude est mis en cache (MONITORINK_USAGE_TTL=120s) avec
repli sur la dernière valeur connue en cas d'erreur transitoire.

Kobo : monitorinkloop.sh récupère meta puis png et fait un fbink partiel
(-g file=,x=,y=) sans flash, full refresh (-c -f) en mode full. Refresh 5 min.
This commit is contained in:
jerem
2026-06-15 18:42:32 +02:00
parent ce20d3675d
commit c7395d1c37
8 changed files with 257 additions and 27 deletions

View File

@@ -10,8 +10,9 @@ from __future__ import annotations
import time
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, PlainTextResponse
import frame
import render
from config import config
from integrations import kobo
@@ -46,6 +47,26 @@ async def image(fresh: int = 0, bat: int | None = None, chg: int = 0) -> Respons
)
@app.get("/frame.meta", response_class=PlainTextResponse)
async def frame_meta(client: str = "kobo", bat: int | None = None, chg: int = 0) -> Response:
# Refresh partiel : rend l'image, calcule la zone modifiée vs le dernier frame de ce client,
# et renvoie une ligne "MODE X Y W H SEQ" triviale à parser en shell busybox.
# MODE ∈ {full, partial, noop}. Le PNG correspondant est récupéré via /frame.png.
kobo.record(bat, bool(chg))
info = await frame.compute_frame(client)
line = f"{info['mode']} {info['x']} {info['y']} {info['w']} {info['h']} {info['seq']}"
return PlainTextResponse(line, headers={"Cache-Control": "no-store"})
@app.get("/frame.png")
async def frame_png(client: str = "kobo") -> Response:
# PNG décidé lors du dernier /frame.meta (crop en partial, image pleine en full).
png = frame.get_png(client)
if png is None:
return Response(status_code=503)
return Response(content=png, media_type="image/png", headers={"Cache-Control": "no-store"})
@app.get("/debug.html", response_class=HTMLResponse)
async def debug_html() -> str:
context = await render.build_context()

View File

@@ -88,6 +88,22 @@ class Config:
cache_ttl_seconds: int = field(
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
)
# Intervalle mini entre deux appels réels à l'endpoint /usage de Claude (rate-limité).
# Indépendant de la cadence de rendu : protège du 429 quand on rend souvent (dev 30 s).
usage_ttl_seconds: int = field(
default_factory=lambda: int(_get("MONITORINK_USAGE_TTL", "120"))
)
# --- Refresh partiel e-ink (endpoints /frame.*) ---
# Un full refresh est forcé tous les N cycles pour effacer le ghosting (1=toujours full).
# En prod 12 (~1 h à 5 min/cycle) ; en dev on descend à 2 (~1 min à 30 s/cycle).
full_refresh_every: int = field(
default_factory=lambda: int(_get("MONITORINK_FULL_EVERY", "12"))
)
# Si la zone modifiée dépasse cette fraction de l'écran, on bascule en full (partiel inutile).
partial_max_ratio: float = field(
default_factory=lambda: float(_get("MONITORINK_PARTIAL_MAX_RATIO", "0.6"))
)
@property
def ha_entities(self) -> list[HAEntity]:

95
backend/frame.py Normal file
View File

@@ -0,0 +1,95 @@
"""Refresh partiel e-ink : calcule, par client, la zone du dashboard qui a changé depuis
l'image précédemment servie, et décide d'un refresh partiel (crop) ou complet (full).
Le diff se fait ici (côté serveur, PIL dispo) ; la Kobo ne reçoit qu'un PNG prêt à afficher
(crop en partial, image pleine en full) + son offset, via les endpoints /frame.meta et /frame.png.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from PIL import Image, ImageChops
import render
from config import config
# Seuil de diff (0-255) sous lequel on considère deux pixels identiques. Le rendu PNG est
# lossless et déterministe : les zones inchangées sont strictement identiques, ce seuil ne
# sert qu'à absorber un éventuel bruit résiduel.
_DIFF_THRESHOLD = 16
@dataclass
class _ClientState:
prev_image: Image.Image | None = None
since_full: int = 0 # cycles écoulés depuis le dernier full refresh
seq: int = 0 # compteur monotone, identifie le frame courant
png: bytes = b"" # PNG à servir (crop ou image pleine)
mode: str = "full"
region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_clients: dict[str, _ClientState] = {}
def _changed_bbox(prev: Image.Image, cur: Image.Image) -> tuple[int, int, int, int] | None:
"""Rectangle (left, upper, right, lower) englobant les pixels modifiés, ou None si identiques."""
diff = ImageChops.difference(prev, cur)
if _DIFF_THRESHOLD:
diff = diff.point(lambda p: 255 if p > _DIFF_THRESHOLD else 0)
return diff.getbbox()
async def compute_frame(client: str) -> dict:
"""Rend le dashboard, calcule le diff vs l'image précédente du client, met à jour son état
et renvoie {mode, x, y, w, h, seq}. Le PNG correspondant est stocké pour /frame.png."""
state = _clients.setdefault(client, _ClientState())
async with state.lock:
cur = await render.render_image()
full_w, full_h = cur.size
state.seq += 1
force_full = (
state.prev_image is None
or state.since_full + 1 >= config.full_refresh_every
)
bbox = None if force_full else _changed_bbox(state.prev_image, cur)
if not force_full and bbox is not None:
left, upper, right, lower = bbox
w, h = right - left, lower - upper
ratio = (w * h) / float(full_w * full_h)
if ratio > config.partial_max_ratio:
force_full = True
if force_full:
state.png = render.encode_png(cur)
state.mode = "full"
state.region = (0, 0, full_w, full_h)
state.since_full = 0
elif bbox is None:
# Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve
# le PNG précédent et on incrémente quand même since_full pour garder la cadence du
# full refresh horaire (basée sur le temps écoulé).
state.mode = "noop"
state.region = (0, 0, 0, 0)
state.since_full += 1
else:
left, upper, right, lower = bbox
state.png = render.encode_png(cur.crop(bbox))
state.mode = "partial"
state.region = (left, upper, right - left, lower - upper)
state.since_full += 1
state.prev_image = cur
x, y, w, h = state.region
return {"mode": state.mode, "x": x, "y": y, "w": w, "h": h, "seq": state.seq}
def get_png(client: str) -> bytes | None:
"""PNG stocké lors du dernier compute_frame() pour ce client (None si jamais calculé)."""
state = _clients.get(client)
return state.png if state and state.png else None

View File

@@ -202,7 +202,31 @@ def _burn_rate_from_ccusage() -> float | None:
return None
# Dernier usage récupéré avec succès : sert de cache (throttle) ET de repli en cas d'erreur
# transitoire (429, réseau) pour ne pas afficher "HTTP 429" sur l'e-ink. Les libellés dynamiques
# (resets_in_human) restent corrects car recalculés à la volée depuis resets_at.
_usage_cache: dict[str, object] = {"value": None, "ts": 0.0}
async def fetch_usage() -> ClaudeUsage:
now = time.time()
cached = _usage_cache["value"]
if isinstance(cached, ClaudeUsage) and cached.ok and (now - float(_usage_cache["ts"])) < config.usage_ttl_seconds:
return cached
result = await _fetch_usage()
if result.ok:
_usage_cache["value"] = result
_usage_cache["ts"] = now
return result
# Erreur (429, réseau, auth transitoire) : on réaffiche la dernière valeur correcte connue
# plutôt qu'un message d'erreur, le temps que ça se rétablisse.
if isinstance(cached, ClaudeUsage) and cached.ok:
return cached
return result
async def _fetch_usage() -> ClaudeUsage:
if not os.path.exists(config.claude_creds_path):
return ClaudeUsage(ok=False, error="credentials Claude absents — login isolé requis")

View File

@@ -85,8 +85,9 @@ def render_html(context: dict) -> str:
return _env.get_template("dashboard.html").render(**context)
async def render_png() -> bytes:
"""Rend le dashboard en PNG niveaux de gris prêt pour l'e-ink de la Kobo."""
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)
@@ -107,7 +108,16 @@ async def render_png() -> bytes:
# 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)
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())