diff --git a/.env.example b/.env.example index e113207..da3e972 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,14 @@ MONITORINK_HEIGHT=1264 MONITORINK_ROTATE=cw MONITORINK_CACHE_TTL=120 +# Refresh partiel e-ink (endpoints /frame.meta + /frame.png). +# Full refresh (efface le ghosting) tous les N cycles. PROD=12 (~1 h à 5 min/cycle), DEV=2. +MONITORINK_FULL_EVERY=12 +# Bascule en full si la zone modifiée dépasse cette fraction de l'écran (partiel inutile). +MONITORINK_PARTIAL_MAX_RATIO=0.6 +# Intervalle mini entre deux appels réels à l'endpoint /usage de Claude (anti-429). Secondes. +MONITORINK_USAGE_TTL=120 + # Météo (Open-Meteo, sans clé) — coordonnées MONITORINK_LAT=48.8566 MONITORINK_LON=2.3522 diff --git a/backend/app.py b/backend/app.py index fface94..5ad7eb4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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() diff --git a/backend/config.py b/backend/config.py index ff4f786..c8c2444 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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]: diff --git a/backend/frame.py b/backend/frame.py new file mode 100644 index 0000000..967f0f3 --- /dev/null +++ b/backend/frame.py @@ -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 diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index f787424..8d20f1e 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -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") diff --git a/backend/render.py b/backend/render.py index 3d7c762..d5f312c 100644 --- a/backend/render.py +++ b/backend/render.py @@ -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()) diff --git a/kobo/monitorink.sh b/kobo/monitorink.sh index 8117472..51118d6 100755 --- a/kobo/monitorink.sh +++ b/kobo/monitorink.sh @@ -15,7 +15,8 @@ cd "$BASE" || exit 1 # --- Configuration --- export MONITORINK_URL="http://192.168.0.43:8899/image.png" -export MONITORINK_REFRESH=300 # PROD: refresh 5 min +export MONITORINK_REFRESH=300 # PROD: refresh partiel 5 min +# Cadence du full refresh : côté SERVEUR via MONITORINK_FULL_EVERY (PROD=12 ~1 h à 5 min/cycle). echo "===== monitorink start $(date) =====" >> "$LOG"; sync diff --git a/kobo/monitorinkloop.sh b/kobo/monitorinkloop.sh index 5a238a3..33c0076 100755 --- a/kobo/monitorinkloop.sh +++ b/kobo/monitorinkloop.sh @@ -10,6 +10,11 @@ BASE="$(dirname "$0")" cd "$BASE" || exit 1 IMAGE_URL="${MONITORINK_URL:-https://monitorink.homelab.nestor-server.fr/image.png}" +# Endpoints du refresh partiel, dérivés de l'URL image (.../image.png -> .../frame.meta|frame.png). +BASE_URL="${IMAGE_URL%/image.png}" +META_URL="$BASE_URL/frame.meta" +FRAME_URL="$BASE_URL/frame.png" +CLIENT="${MONITORINK_CLIENT:-kobo}" REFRESH="${MONITORINK_REFRESH:-600}" TMP="/tmp/monitorink.png" @@ -36,30 +41,73 @@ read_battery() { echo "" } -fetch() { - # On pousse la batterie de la Kobo en paramètres d'URL (le backend la mémorise). - url="$IMAGE_URL" +bat_query() { + # Renvoie "bat=CAP&chg=CHG" pour pousser la batterie au backend (vide si introuvable). bat="$(read_battery)" if [ -n "$bat" ]; then cap="${bat%%|*}"; chg="${bat##*|}" - case "$url" in *\?*) sep="&" ;; *) sep="?" ;; esac - url="${url}${sep}bat=${cap}&chg=${chg}" + echo "bat=${cap}&chg=${chg}" + fi +} + +http_get() { + # $1=url, $2=fichier de sortie ("-" = stdout). busybox wget puis fallback curl. + url="$1"; out="$2" + if [ "$out" = "-" ]; then + "$BUSYBOX" wget -q -T 30 -O - "$url" 2>/dev/null && return 0 + command -v curl >/dev/null 2>&1 && curl -fsSL -m 30 "$url" 2>/dev/null && return 0 + else + "$BUSYBOX" wget -q -T 30 -O "$out" "$url" 2>/dev/null && return 0 + command -v curl >/dev/null 2>&1 && curl -fsSL -m 30 -o "$out" "$url" 2>/dev/null && return 0 fi - # busybox wget (toujours présent), fallback curl si dispo dans le PATH. - "$BUSYBOX" wget -q -T 30 -O "$TMP" "$url" 2>/dev/null && return 0 - command -v curl >/dev/null 2>&1 && curl -fsSL -m 30 -o "$TMP" "$url" 2>/dev/null && return 0 return 1 } +fetch_meta() { + # Récupère la ligne "MODE X Y W H SEQ" du backend (avec batterie + client). Vide si KO. + murl="$META_URL?client=$CLIENT" + q="$(bat_query)"; [ -n "$q" ] && murl="$murl&$q" + http_get "$murl" - +} + # Ferme les FD hérités pour ne pas bloquer l'éjection USB. exec 3>&- 2>/dev/null log "boucle démarrée — BASE=$BASE URL=$IMAGE_URL refresh=${REFRESH}s" log "fbink présent: $([ -x "$FBINK" ] && echo oui || echo NON) ; busybox: $([ -x "$BUSYBOX" ] && echo oui || echo NON)" -display() { +display_full() { + # Full refresh : clear + waveform complète (le "flash" e-ink), efface le ghosting. "$FBINK" -g file="$TMP",valign=CENTER,halign=CENTER -c -f - log "fbink rc=$?" + log "display full rc=$?" +} + +display_partial() { + # Refresh partiel : dessine le crop à l'offset (x,y) ; ni -c ni -f -> seule cette zone est + # rafraîchie, en waveform partielle (sans flash). $1=x $2=y (coords portrait, origine HG). + "$FBINK" -g file="$TMP",x="$1",y="$2" + log "display partial x=$1 y=$2 rc=$?" +} + +offline() { + log "hors ligne" + "$FBINK" -pmh "Monitorink hors ligne ($(date '+%H:%M'))" +} + +show_frame() { + # Récupère le crop/full image stocké côté serveur et l'affiche selon le mode. + # $1=mode $2=x $3=y + if ! http_get "$FRAME_URL?client=$CLIENT" "$TMP"; then + log "frame.png KO (mode=$1)" + [ "$1" = "full" ] && offline + return 1 + fi + log "frame.png OK ($(wc -c < "$TMP" 2>/dev/null) octets)" + if [ "$1" = "partial" ]; then + display_partial "$2" "$3" + else + display_full + fi } frontlight_off() { @@ -129,20 +177,27 @@ while true; do wifi_up - if fetch; then - log "fetch OK ($(wc -c < "$TMP" 2>/dev/null) octets)" - display - else + meta="$(fetch_meta)" + if [ -z "$meta" ]; then # WiFi peut-être tombé : on tente une reconnexion puis un re-essai. - log "fetch KO -> reconnexion WiFi" + log "meta KO -> reconnexion WiFi" wifi_up - if fetch; then - log "fetch OK après reco ($(wc -c < "$TMP" 2>/dev/null) octets)" - display - else - log "fetch ECHEC (hors ligne)" - "$FBINK" -pmh "Monitorink hors ligne ($(date '+%H:%M'))" - fi + meta="$(fetch_meta)" + fi + + if [ -n "$meta" ]; then + # shellcheck disable=SC2086 + set -- $meta # MODE X Y W H SEQ + mode="$1"; mx="$2"; my="$3" + log "meta: mode=$mode x=$mx y=$my w=$4 h=$5 seq=$6" + case "$mode" in + noop) log "aucun changement -> pas de refresh" ;; + partial) show_frame partial "$mx" "$my" ;; + *) show_frame full ;; # full ou valeur inattendue -> full refresh sûr + esac + else + log "meta ECHEC" + offline fi ./scripts/ledToggle.sh off 2>/dev/null