"""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 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 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 # 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 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" 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_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) 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, 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 erronée. On force alors un full refresh propre.""" state = _clients.setdefault(client, _ClientState()) async with state.lock: if reset: state.prev_image = None # -> force_full ci-dessous cur = await render.render_image() full_w, full_h = cur.size state.seq += 1 now = time.monotonic() force_full = ( state.prev_image is None or (now - state.last_full_at) >= config.full_refresh_interval_minutes * 60 ) regions = [] if force_full else _changed_regions(state.prev_image, cur) if force_full: state.pngs = [render.encode_png(cur)] state.mode = "full" 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.regions = [] else: state.pngs = [render.encode_png(cur.crop((x, y, x + w, y + h))) for (x, y, w, h) in regions] state.mode = "partial" state.regions = regions state.prev_image = cur return {"mode": state.mode, "seq": state.seq, "regions": state.regions} 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) if not state or region < 0 or region >= len(state.pngs): return None return state.pngs[region] or None