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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
95
backend/frame.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'))"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user