diff --git a/.env.example b/.env.example index da3e972..3e772f7 100644 --- a/.env.example +++ b/.env.example @@ -19,10 +19,9 @@ 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 +# Full refresh (flash, efface le ghosting) au 1er lancement/reset puis toutes les N minutes ; +# entre deux, uniquement des partiels serrés sur les zones modifiées. PROD=120 (2 h), DEV=1-2. +MONITORINK_FULL_INTERVAL_MIN=120 # Intervalle mini entre deux appels réels à l'endpoint /usage de Claude (anti-429). Secondes. MONITORINK_USAGE_TTL=120 diff --git a/backend/app.py b/backend/app.py index 49a3037..3946ce3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -51,20 +51,25 @@ async def image(fresh: int = 0, bat: int | None = None, chg: int = 0) -> Respons async def frame_meta( client: str = "kobo", bat: int | None = None, chg: int = 0, reset: 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. + # Refresh partiel : rend l'image, calcule les zones modifiées vs le dernier frame de ce client, + # et renvoie un bloc texte trivial à parser en shell busybox : + # MODE SEQ NREGIONS + # i x y w h (NREGIONS lignes, i = index pour /frame.png?region=i) + # MODE ∈ {full, partial, noop}. Les PNG correspondants sont récupérés via /frame.png. # reset=1 (1er cycle après un (re)démarrage Kobo) -> oublie l'état et force un full refresh. kobo.record(bat, bool(chg)) info = await frame.compute_frame(client, reset=bool(reset)) - line = f"{info['mode']} {info['x']} {info['y']} {info['w']} {info['h']} {info['seq']}" - return PlainTextResponse(line, headers={"Cache-Control": "no-store"}) + regions = info["regions"] + lines = [f"{info['mode']} {info['seq']} {len(regions)}"] + lines += [f"{i} {x} {y} {w} {h}" for i, (x, y, w, h) in enumerate(regions)] + return PlainTextResponse("\n".join(lines), 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) +async def frame_png(client: str = "kobo", region: int = 0) -> Response: + # PNG de la région `region` décidée lors du dernier /frame.meta (un crop par zone modifiée en + # partial, image pleine en full). + png = frame.get_png(client, region) if png is None: return Response(status_code=503) return Response(content=png, media_type="image/png", headers={"Cache-Control": "no-store"}) diff --git a/backend/config.py b/backend/config.py index c8c2444..44183ad 100644 --- a/backend/config.py +++ b/backend/config.py @@ -95,14 +95,11 @@ class Config: ) # --- 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")) + # Un full refresh (flash, efface le ghosting) est forcé au 1er lancement/reset puis toutes les + # N minutes — indépendamment du cycle Kobo. Entre deux, on ne fait que des partiels serrés sur + # les zones réellement modifiées. En dev on descend à 1-2 min pour tester rapidement. + full_refresh_interval_minutes: int = field( + default_factory=lambda: int(_get("MONITORINK_FULL_INTERVAL_MIN", "120")) ) @property diff --git a/backend/frame.py b/backend/frame.py index 68e9945..543d76b 100644 --- a/backend/frame.py +++ b/backend/frame.py @@ -1,12 +1,20 @@ -"""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). +"""Refresh partiel e-ink : calcule, par client, les zones du dashboard qui ont changé depuis +l'image précédemment servie, et décide d'un refresh partiel (un ou plusieurs crops) ou complet. -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. +Le diff se fait ici (côté serveur, PIL dispo) ; la Kobo ne reçoit que des PNG prêts à afficher +(un crop par zone modifiée en partial, image pleine en full) + leurs offsets, via les endpoints +/frame.meta et /frame.png. + +Pourquoi plusieurs zones : un seul rectangle englobant (getbbox) s'étire dès que deux blocs +distants changent au même cycle (ex. météo en haut + heure de MaJ ailleurs), ce qui forçait à tort +un full. On détecte donc les bandes horizontales modifiées disjointes et on rafraîchit chacune +séparément, en partiel (sans flash). Le full flashy n'arrive plus qu'au 1er lancement/reset et à +intervalle de temps fixe (cf. full_refresh_interval_minutes) pour nettoyer le ghosting. """ from __future__ import annotations import asyncio +import time from dataclasses import dataclass, field from PIL import Image, ImageChops @@ -19,32 +27,78 @@ from config import config # sert qu'à absorber un éventuel bruit résiduel. _DIFF_THRESHOLD = 16 +# Deux bandes de lignes modifiées séparées par moins de _BAND_GAP pixels inchangés sont fusionnées +# (évite d'éclater un même bloc en multiples régions ; économise des appels fbink sur la Kobo). +_BAND_GAP = 24 + +# Plafond de régions par cycle : au-delà, on fusionne tout en une seule région englobante (un grand +# partiel flashless reste préférable à un full flashy parasite). +_MAX_REGIONS = 6 + @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) + last_full_at: float = 0.0 # time.monotonic() du dernier full refresh + seq: int = 0 # compteur monotone, identifie le frame courant + pngs: list[bytes] = field(default_factory=list) # un PNG par région (crop ou image pleine) mode: str = "full" - region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h + regions: list[tuple[int, int, int, int]] = field(default_factory=list) # (x, y, w, h) par région 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.""" +def _changed_regions(prev: Image.Image, cur: Image.Image) -> list[tuple[int, int, int, int]]: + """Liste de rectangles (x, y, w, h) serrés couvrant les pixels modifiés, ou [] si identiques. + + Détecte les bandes horizontales modifiées disjointes (les blocs du dashboard sont séparés + verticalement), fusionne les bandes proches, et rabote chaque bande à son x-extent réel. + """ diff = ImageChops.difference(prev, cur) if _DIFF_THRESHOLD: diff = diff.point(lambda p: 255 if p > _DIFF_THRESHOLD else 0) - return diff.getbbox() + if diff.getbbox() is None: + return [] + + full_w, full_h = diff.size + # Activité par ligne : on réduit la largeur à 1 (BOX = moyenne) ; une ligne avec au moins un + # pixel modifié donne une valeur > 0. getdata() -> H valeurs, une par ligne. + col = diff.resize((1, full_h), Image.BOX) + row_changed = [v > 0 for v in col.getdata()] + + # Regroupe les lignes changées contiguës en bandes [haut, bas), en tolérant un trou < _BAND_GAP. + bands: list[list[int]] = [] # [top, bottom) + for y, changed in enumerate(row_changed): + if not changed: + continue + if bands and y - bands[-1][1] <= _BAND_GAP: + bands[-1][1] = y + 1 + else: + bands.append([y, y + 1]) + + # Pour chaque bande, x/y-extent réel via getbbox sur la tranche. + regions: list[tuple[int, int, int, int]] = [] + for top, bottom in bands: + bbox = diff.crop((0, top, full_w, bottom)).getbbox() + if bbox is None: + continue + left, upper, right, lower = bbox + regions.append((left, top + upper, right - left, lower - upper)) + + # Garde-fou : trop de régions -> une seule région englobante (grand partiel, jamais de full). + if len(regions) > _MAX_REGIONS: + full = diff.getbbox() + left, upper, right, lower = full + return [(left, upper, right - left, lower - upper)] + return regions async def compute_frame(client: str, reset: bool = False) -> 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. + et renvoie {mode, seq, regions:[(x,y,w,h), ...]}. Les PNG correspondants (un par région) sont + stockés pour /frame.png. reset=True (envoyé par la Kobo au 1er cycle après un (re)démarrage) oublie l'image précédente : l'écran a été effacé par le reboot, un diff partiel se poserait sur une base @@ -57,45 +111,37 @@ async def compute_frame(client: str, reset: bool = False) -> dict: full_w, full_h = cur.size state.seq += 1 + now = time.monotonic() force_full = ( state.prev_image is None - or state.since_full + 1 >= config.full_refresh_every + or (now - state.last_full_at) >= config.full_refresh_interval_minutes * 60 ) - 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 + regions = [] if force_full else _changed_regions(state.prev_image, cur) if force_full: - state.png = render.encode_png(cur) + state.pngs = [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.regions = [(0, 0, full_w, full_h)] + state.last_full_at = now + elif not regions: + # Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve les + # PNG précédents ; last_full_at reste figé (cadence du full basée sur le temps écoulé). state.mode = "noop" - state.region = (0, 0, 0, 0) - state.since_full += 1 + state.regions = [] else: - left, upper, right, lower = bbox - state.png = render.encode_png(cur.crop(bbox)) + state.pngs = [render.encode_png(cur.crop((x, y, x + w, y + h))) for (x, y, w, h) in regions] state.mode = "partial" - state.region = (left, upper, right - left, lower - upper) - state.since_full += 1 + state.regions = regions 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} + return {"mode": state.mode, "seq": state.seq, "regions": state.regions} -def get_png(client: str) -> bytes | None: - """PNG stocké lors du dernier compute_frame() pour ce client (None si jamais calculé).""" +def get_png(client: str, region: int = 0) -> bytes | None: + """PNG de la région d'index `region` stockée lors du dernier compute_frame() pour ce client + (None si jamais calculé ou index hors borne).""" state = _clients.get(client) - return state.png if state and state.png else None + if not state or region < 0 or region >= len(state.pngs): + return None + return state.pngs[region] or None diff --git a/kobo/monitorink.sh b/kobo/monitorink.sh index 20bf841..392c8e1 100755 --- a/kobo/monitorink.sh +++ b/kobo/monitorink.sh @@ -16,8 +16,8 @@ cd "$BASE" || exit 1 # --- Configuration --- export MONITORINK_URL="http://192.168.0.43:8899/image.png" export MONITORINK_REFRESH=900 # PROD: refresh partiel 15 min (moins de réveils = batterie) -# Cadence du full refresh : côté SERVEUR via MONITORINK_FULL_EVERY. À 15 min/cycle, FULL_EVERY=4 -# -> full refresh ~1 h (ajuster côté backend si besoin pour limiter le ghosting). +# Cadence du full refresh : côté SERVEUR via MONITORINK_FULL_INTERVAL_MIN (défaut 120 = 2 h), +# indépendante du cycle. Entre deux fulls, seuls les blocs modifiés sont rafraîchis (partiel). echo "===== monitorink start $(date) =====" >> "$LOG"; sync diff --git a/kobo/monitorinkloop.sh b/kobo/monitorinkloop.sh index b4b5036..dc0c8d6 100755 --- a/kobo/monitorinkloop.sh +++ b/kobo/monitorinkloop.sh @@ -97,20 +97,38 @@ offline() { "$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 +fetch_region() { + # Récupère le PNG de la région $1 stocké côté serveur dans $TMP. 0 = OK. + if http_get "$FRAME_URL?client=$CLIENT®ion=$1" "$TMP"; then + log "frame.png OK region=$1 ($(wc -c < "$TMP" 2>/dev/null) octets)" + return 0 fi + log "frame.png KO region=$1" + return 1 +} + +display_meta() { + # Parse le bloc meta multi-ligne et affiche selon le mode : + # MODE SEQ N + # i x y w h (N lignes ; i = index région pour /frame.png?region=i) + # Lecture ligne-à-ligne : read consomme l'en-tête, le while lit les régions restantes (même + # stdin). Le sous-shell de pipe convient : chaque région est fetch + affichée sur place. + printf '%s\n' "$1" | { + read mode seq n + log "meta: mode=$mode seq=$seq regions=$n" + case "$mode" in + noop) log "aucun changement -> pas de refresh" ;; + partial) + while read idx x y w h; do + [ -n "$idx" ] || continue + fetch_region "$idx" && display_partial "$x" "$y" + done + ;; + *) # full ou valeur inattendue -> full refresh sûr (région 0 = image pleine) + if fetch_region 0; then display_full; else offline; fi + ;; + esac + } } frontlight_off() { @@ -213,15 +231,7 @@ while true; do 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 + display_meta "$meta" FIRST=0 # meta obtenue : le reset n'a plus lieu d'être pour les cycles suivants else log "meta ECHEC"