Refresh e-ink: multi-régions + full toutes les 2h (basé temps)

Le full refresh apparaissait trop souvent: getbbox() renvoyait un seul
rectangle englobant tous les pixels modifiés, donc météo (haut) + heure de
MaJ (ailleurs) qui changeaient au même cycle produisaient un rectangle
quasi plein écran -> ratio > partial_max_ratio -> full forcé.

- frame.py: détection des bandes horizontales modifiées disjointes
  (_changed_regions), refresh partiel serré par zone. Full basé sur le
  temps écoulé (last_full_at + time.monotonic) au lieu d'un compteur de
  cycles. État pngs/regions en liste, get_png(client, region).
- config.py: full_refresh_interval_minutes (MONITORINK_FULL_INTERVAL_MIN,
  défaut 120). Suppression de partial_max_ratio.
- app.py: /frame.meta renvoie un bloc multi-ligne "MODE SEQ N" + N régions
  "i x y w h"; /frame.png?region=i.
- monitorinkloop.sh: display_meta parse le bloc et fait N fbink partiels.
This commit is contained in:
jerem
2026-06-16 14:06:49 +02:00
parent d7f52210e7
commit ca4febbc44
6 changed files with 140 additions and 83 deletions

View File

@@ -19,10 +19,9 @@ MONITORINK_ROTATE=cw
MONITORINK_CACHE_TTL=120 MONITORINK_CACHE_TTL=120
# Refresh partiel e-ink (endpoints /frame.meta + /frame.png). # 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. # Full refresh (flash, efface le ghosting) au 1er lancement/reset puis toutes les N minutes ;
MONITORINK_FULL_EVERY=12 # entre deux, uniquement des partiels serrés sur les zones modifiées. PROD=120 (2 h), DEV=1-2.
# Bascule en full si la zone modifiée dépasse cette fraction de l'écran (partiel inutile). MONITORINK_FULL_INTERVAL_MIN=120
MONITORINK_PARTIAL_MAX_RATIO=0.6
# Intervalle mini entre deux appels réels à l'endpoint /usage de Claude (anti-429). Secondes. # Intervalle mini entre deux appels réels à l'endpoint /usage de Claude (anti-429). Secondes.
MONITORINK_USAGE_TTL=120 MONITORINK_USAGE_TTL=120

View File

@@ -51,20 +51,25 @@ async def image(fresh: int = 0, bat: int | None = None, chg: int = 0) -> Respons
async def frame_meta( async def frame_meta(
client: str = "kobo", bat: int | None = None, chg: int = 0, reset: int = 0 client: str = "kobo", bat: int | None = None, chg: int = 0, reset: int = 0
) -> Response: ) -> Response:
# Refresh partiel : rend l'image, calcule la zone modifiée vs le dernier frame de ce client, # Refresh partiel : rend l'image, calcule les zones modifiées vs le dernier frame de ce client,
# et renvoie une ligne "MODE X Y W H SEQ" triviale à parser en shell busybox. # et renvoie un bloc texte trivial à parser en shell busybox :
# MODE ∈ {full, partial, noop}. Le PNG correspondant est récupéré via /frame.png. # 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. # reset=1 (1er cycle après un (re)démarrage Kobo) -> oublie l'état et force un full refresh.
kobo.record(bat, bool(chg)) kobo.record(bat, bool(chg))
info = await frame.compute_frame(client, reset=bool(reset)) info = await frame.compute_frame(client, reset=bool(reset))
line = f"{info['mode']} {info['x']} {info['y']} {info['w']} {info['h']} {info['seq']}" regions = info["regions"]
return PlainTextResponse(line, headers={"Cache-Control": "no-store"}) 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") @app.get("/frame.png")
async def frame_png(client: str = "kobo") -> Response: async def frame_png(client: str = "kobo", region: int = 0) -> Response:
# PNG décidé lors du dernier /frame.meta (crop en partial, image pleine en full). # PNG de la région `region` décidée lors du dernier /frame.meta (un crop par zone modifiée en
png = frame.get_png(client) # partial, image pleine en full).
png = frame.get_png(client, region)
if png is None: if png is None:
return Response(status_code=503) return Response(status_code=503)
return Response(content=png, media_type="image/png", headers={"Cache-Control": "no-store"}) return Response(content=png, media_type="image/png", headers={"Cache-Control": "no-store"})

View File

@@ -95,14 +95,11 @@ class Config:
) )
# --- Refresh partiel e-ink (endpoints /frame.*) --- # --- Refresh partiel e-ink (endpoints /frame.*) ---
# Un full refresh est forcé tous les N cycles pour effacer le ghosting (1=toujours full). # Un full refresh (flash, efface le ghosting) est forcé au 1er lancement/reset puis toutes les
# En prod 12 (~1 h à 5 min/cycle) ; en dev on descend à 2 (~1 min à 30 s/cycle). # N minutes — indépendamment du cycle Kobo. Entre deux, on ne fait que des partiels serrés sur
full_refresh_every: int = field( # les zones réellement modifiées. En dev on descend à 1-2 min pour tester rapidement.
default_factory=lambda: int(_get("MONITORINK_FULL_EVERY", "12")) full_refresh_interval_minutes: int = field(
) default_factory=lambda: int(_get("MONITORINK_FULL_INTERVAL_MIN", "120"))
# 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 @property

View File

@@ -1,12 +1,20 @@
"""Refresh partiel e-ink : calcule, par client, la zone du dashboard qui a changé depuis """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 (crop) ou complet (full). 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 Le diff se fait ici (côté serveur, PIL dispo) ; la Kobo ne reçoit que des PNG prêts à afficher
(crop en partial, image pleine en full) + son offset, via les endpoints /frame.meta et /frame.png. (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 from __future__ import annotations
import asyncio import asyncio
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from PIL import Image, ImageChops from PIL import Image, ImageChops
@@ -19,32 +27,78 @@ from config import config
# sert qu'à absorber un éventuel bruit résiduel. # sert qu'à absorber un éventuel bruit résiduel.
_DIFF_THRESHOLD = 16 _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 @dataclass
class _ClientState: class _ClientState:
prev_image: Image.Image | None = None prev_image: Image.Image | None = None
since_full: int = 0 # cycles écoulés depuis le dernier full refresh last_full_at: float = 0.0 # time.monotonic() du dernier full refresh
seq: int = 0 # compteur monotone, identifie le frame courant seq: int = 0 # compteur monotone, identifie le frame courant
png: bytes = b"" # PNG à servir (crop ou image pleine) pngs: list[bytes] = field(default_factory=list) # un PNG par région (crop ou image pleine)
mode: str = "full" 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) lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_clients: dict[str, _ClientState] = {} _clients: dict[str, _ClientState] = {}
def _changed_bbox(prev: Image.Image, cur: Image.Image) -> tuple[int, int, int, int] | None: def _changed_regions(prev: Image.Image, cur: Image.Image) -> list[tuple[int, int, int, int]]:
"""Rectangle (left, upper, right, lower) englobant les pixels modifiés, ou None si identiques.""" """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) diff = ImageChops.difference(prev, cur)
if _DIFF_THRESHOLD: if _DIFF_THRESHOLD:
diff = diff.point(lambda p: 255 if p > _DIFF_THRESHOLD else 0) 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: 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 """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 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 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 full_w, full_h = cur.size
state.seq += 1 state.seq += 1
now = time.monotonic()
force_full = ( force_full = (
state.prev_image is None 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) regions = [] if force_full else _changed_regions(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: if force_full:
state.png = render.encode_png(cur) state.pngs = [render.encode_png(cur)]
state.mode = "full" state.mode = "full"
state.region = (0, 0, full_w, full_h) state.regions = [(0, 0, full_w, full_h)]
state.since_full = 0 state.last_full_at = now
elif bbox is None: elif not regions:
# Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve # Aucune différence : on ne rafraîchit rien (la Kobo ignore ce cycle). On conserve les
# le PNG précédent et on incrémente quand même since_full pour garder la cadence du # PNG précédents ; last_full_at reste figé (cadence du full basée sur le temps écoulé).
# full refresh horaire (basée sur le temps écoulé).
state.mode = "noop" state.mode = "noop"
state.region = (0, 0, 0, 0) state.regions = []
state.since_full += 1
else: else:
left, upper, right, lower = bbox state.pngs = [render.encode_png(cur.crop((x, y, x + w, y + h))) for (x, y, w, h) in regions]
state.png = render.encode_png(cur.crop(bbox))
state.mode = "partial" state.mode = "partial"
state.region = (left, upper, right - left, lower - upper) state.regions = regions
state.since_full += 1
state.prev_image = cur state.prev_image = cur
x, y, w, h = state.region return {"mode": state.mode, "seq": state.seq, "regions": state.regions}
return {"mode": state.mode, "x": x, "y": y, "w": w, "h": h, "seq": state.seq}
def get_png(client: str) -> bytes | None: def get_png(client: str, region: int = 0) -> bytes | None:
"""PNG stocké lors du dernier compute_frame() pour ce client (None si jamais calculé).""" """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) 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

View File

@@ -16,8 +16,8 @@ cd "$BASE" || exit 1
# --- Configuration --- # --- Configuration ---
export MONITORINK_URL="http://192.168.0.43:8899/image.png" 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) 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 # Cadence du full refresh : côté SERVEUR via MONITORINK_FULL_INTERVAL_MIN (défaut 120 = 2 h),
# -> full refresh ~1 h (ajuster côté backend si besoin pour limiter le ghosting). # indépendante du cycle. Entre deux fulls, seuls les blocs modifiés sont rafraîchis (partiel).
echo "===== monitorink start $(date) =====" >> "$LOG"; sync echo "===== monitorink start $(date) =====" >> "$LOG"; sync

View File

@@ -97,20 +97,38 @@ offline() {
"$FBINK" -pmh "Monitorink hors ligne ($(date '+%H:%M'))" "$FBINK" -pmh "Monitorink hors ligne ($(date '+%H:%M'))"
} }
show_frame() { fetch_region() {
# Récupère le crop/full image stocké côté serveur et l'affiche selon le mode. # Récupère le PNG de la région $1 stocké côté serveur dans $TMP. 0 = OK.
# $1=mode $2=x $3=y if http_get "$FRAME_URL?client=$CLIENT&region=$1" "$TMP"; then
if ! http_get "$FRAME_URL?client=$CLIENT" "$TMP"; then log "frame.png OK region=$1 ($(wc -c < "$TMP" 2>/dev/null) octets)"
log "frame.png KO (mode=$1)" return 0
[ "$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 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() { frontlight_off() {
@@ -213,15 +231,7 @@ while true; do
fi fi
if [ -n "$meta" ]; then if [ -n "$meta" ]; then
# shellcheck disable=SC2086 display_meta "$meta"
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
FIRST=0 # meta obtenue : le reset n'a plus lieu d'être pour les cycles suivants FIRST=0 # meta obtenue : le reset n'a plus lieu d'être pour les cycles suivants
else else
log "meta ECHEC" log "meta ECHEC"