"""Serveur Monitorink : expose le dashboard en PNG pour la Kobo. Endpoints : GET /image.png -> dashboard 1264x1680 niveaux de gris (avec cache TTL) GET /debug.html -> HTML brut (itération design, pas de screenshot) GET /health -> sonde de vie """ from __future__ import annotations import time from fastapi import FastAPI, Response from fastapi.responses import HTMLResponse, PlainTextResponse import frame import render from config import config from integrations import kobo app = FastAPI(title="Monitorink", docs_url=None, redoc_url=None) _cache: dict[str, object] = {"png": None, "ts": 0.0} @app.get("/health") async def health() -> dict: return {"status": "ok"} @app.get("/image.png") async def image(fresh: int = 0, bat: int | None = None, chg: int = 0) -> Response: # La Kobo pousse sa batterie ici (bat=0-100, chg=1 si en charge) à chaque fetch. kobo.record(bat, bool(chg)) now = time.time() cached = _cache["png"] age = now - float(_cache["ts"]) if cached and not fresh and age < config.cache_ttl_seconds: png = cached # type: ignore[assignment] else: png = await render.render_png() _cache["png"] = png _cache["ts"] = now return Response( content=png, # type: ignore[arg-type] media_type="image/png", headers={"Cache-Control": "no-store"}, ) @app.get("/frame.meta", response_class=PlainTextResponse) 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. # 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"}) @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) if png is None: return Response(status_code=503) return Response(content=png, media_type="image/png", headers={"Cache-Control": "no-store"}) @app.get("/debug.html", response_class=HTMLResponse) async def debug_html() -> str: context = await render.build_context() return render.render_html(context)