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