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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
"""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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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®ion=$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"
|
||||||
|
|||||||
Reference in New Issue
Block a user