Backend Monitorink: serveur PNG (Claude usage + météo + HA)

This commit is contained in:
2026-06-15 10:49:31 +02:00
commit bc4cf89a4b
16 changed files with 792 additions and 0 deletions

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Image Playwright officielle : Chromium + polices + deps déjà présents.
FROM mcr.microsoft.com/playwright/python:v1.49.1-noble
WORKDIR /app
# Polices incluant les emoji (météo/icônes) pour le rendu e-ink.
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

49
backend/app.py Normal file
View File

@@ -0,0 +1,49 @@
"""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
import render
from config import config
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) -> Response:
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("/debug.html", response_class=HTMLResponse)
async def debug_html() -> str:
context = await render.build_context()
return render.render_html(context)

81
backend/config.py Normal file
View File

@@ -0,0 +1,81 @@
"""Configuration centralisée de Monitorink, chargée depuis l'environnement.
Toutes les valeurs sensibles (token Claude, token Home Assistant) viennent de variables
d'environnement / `.env` et ne sont jamais versionnées.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from dotenv import load_dotenv
load_dotenv()
def _get(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _get_list(name: str) -> list[str]:
raw = _get(name)
return [item.strip() for item in raw.split(",") if item.strip()]
@dataclass(frozen=True)
class HAEntity:
"""Une entité Home Assistant à afficher. Format env: `entity_id|Libellé|unité`."""
entity_id: str
label: str
unit: str = ""
@classmethod
def parse(cls, spec: str) -> "HAEntity":
parts = [p.strip() for p in spec.split("|")]
entity_id = parts[0]
label = parts[1] if len(parts) > 1 and parts[1] else entity_id
unit = parts[2] if len(parts) > 2 else ""
return cls(entity_id=entity_id, label=label, unit=unit)
@dataclass(frozen=True)
class Config:
# --- Affichage ---
timezone: str = field(default_factory=lambda: _get("MONITORINK_TZ", "Europe/Paris"))
locale: str = field(default_factory=lambda: _get("MONITORINK_LOCALE", "fr_FR"))
width: int = field(default_factory=lambda: int(_get("MONITORINK_WIDTH", "1264")))
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680")))
# --- Claude ---
claude_token: str = field(default_factory=lambda: _get("MONITORINK_CLAUDE_TOKEN"))
claude_ua: str = field(
default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172")
)
ccusage_enabled: bool = field(
default_factory=lambda: _get("MONITORINK_CCUSAGE", "0") in ("1", "true", "yes")
)
# --- Météo (Open-Meteo, sans clé) ---
weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566")))
weather_lon: float = field(default_factory=lambda: float(_get("MONITORINK_LON", "2.3522")))
# --- Home Assistant ---
ha_base_url: str = field(default_factory=lambda: _get("MONITORINK_HA_URL").rstrip("/"))
ha_token: str = field(default_factory=lambda: _get("MONITORINK_HA_TOKEN"))
# --- Cache / rafraîchissement serveur ---
cache_ttl_seconds: int = field(
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
)
@property
def ha_entities(self) -> list[HAEntity]:
return [HAEntity.parse(s) for s in _get_list("MONITORINK_HA_ENTITIES")]
@property
def ha_enabled(self) -> bool:
return bool(self.ha_base_url and self.ha_token)
config = Config()

View File

View File

@@ -0,0 +1,133 @@
"""Récupération de l'usage de l'abonnement Claude via l'endpoint OAuth `/usage`.
Validé empiriquement (compte Max 5x) :
GET https://api.anthropic.com/api/oauth/usage
headers: Authorization: Bearer <token>, anthropic-beta: oauth-2025-04-20,
User-Agent: claude-code/<version>, Content-Type: application/json
réponse: { five_hour:{utilization,resets_at}, seven_day:{...},
seven_day_opus, seven_day_sonnet, extra_usage }
Le token utilisé est un token longue durée dédié généré par `claude setup-token`
(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401,
on remonte un état d'erreur pour affichage (« token à régénérer »).
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
import httpx
from config import config
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
@dataclass
class Window:
"""Une fenêtre glissante d'usage (5h ou 7j)."""
utilization: float # 0..100
resets_at: datetime | None
@property
def remaining_pct(self) -> float:
return max(0.0, 100.0 - self.utilization)
@property
def resets_in_human(self) -> str:
if not self.resets_at:
return ""
delta = self.resets_at - datetime.now(timezone.utc)
secs = int(delta.total_seconds())
if secs <= 0:
return "bientôt"
h, m = divmod(secs // 60, 60)
if h >= 24:
return f"{h // 24}j {h % 24}h"
if h:
return f"{h}h{m:02d}"
return f"{m}min"
@dataclass
class ClaudeUsage:
ok: bool
error: str | None = None
five_hour: Window | None = None
seven_day: Window | None = None
seven_day_opus: Window | None = None
seven_day_sonnet: Window | None = None
burn_rate: float | None = None # tokens/min (ccusage, optionnel)
def _parse_window(raw: dict | None) -> Window | None:
if not raw:
return None
util = float(raw.get("utilization", 0) or 0)
resets = raw.get("resets_at")
dt = None
if resets:
try:
dt = datetime.fromisoformat(str(resets).replace("Z", "+00:00"))
except ValueError:
dt = None
return Window(utilization=util, resets_at=dt)
def _burn_rate_from_ccusage() -> float | None:
"""Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort)."""
try:
proc = subprocess.run(
["ccusage", "blocks", "--active", "--json"],
capture_output=True,
text=True,
timeout=20,
)
if proc.returncode != 0:
return None
data = json.loads(proc.stdout)
blocks = data.get("blocks") or []
for b in blocks:
if b.get("isActive"):
bp = b.get("burnRate") or {}
rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator")
return float(rate) if rate is not None else None
except (subprocess.SubprocessError, json.JSONDecodeError, ValueError, FileNotFoundError):
return None
return None
async def fetch_usage() -> ClaudeUsage:
if not config.claude_token:
return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant")
headers = {
"Authorization": f"Bearer {config.claude_token}",
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": config.claude_ua,
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(USAGE_URL, headers=headers)
except httpx.HTTPError as exc:
return ClaudeUsage(ok=False, error=f"réseau: {exc}")
if resp.status_code == 401:
return ClaudeUsage(ok=False, error="token expiré — relancer `claude setup-token`")
if resp.status_code != 200:
return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}")
data = resp.json()
burn = _burn_rate_from_ccusage() if config.ccusage_enabled else None
return ClaudeUsage(
ok=True,
five_hour=_parse_window(data.get("five_hour")),
seven_day=_parse_window(data.get("seven_day")),
seven_day_opus=_parse_window(data.get("seven_day_opus")),
seven_day_sonnet=_parse_window(data.get("seven_day_sonnet")),
burn_rate=burn,
)

View File

@@ -0,0 +1,55 @@
"""Statuts Home Assistant via l'API REST (`GET /api/states/<entity_id>`)."""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from config import HAEntity, config
@dataclass
class HAState:
label: str
state: str
unit: str = ""
ok: bool = True
@property
def display(self) -> str:
if not self.ok:
return ""
s = self.state
if s in ("on", "home", "open", "unlocked", "playing"):
s = "ON"
elif s in ("off", "away", "not_home", "closed", "locked", "idle", "paused"):
s = "OFF"
elif s in ("unavailable", "unknown"):
s = "n/d"
return f"{s}{(' ' + self.unit) if self.unit and s not in ('ON', 'OFF', 'n/d') else ''}"
async def fetch_states() -> list[HAState]:
entities = config.ha_entities
if not config.ha_enabled or not entities:
return []
headers = {"Authorization": f"Bearer {config.ha_token}", "Content-Type": "application/json"}
results: list[HAState] = []
async with httpx.AsyncClient(timeout=15, headers=headers) as client:
for ent in entities:
results.append(await _fetch_one(client, ent))
return results
async def _fetch_one(client: httpx.AsyncClient, ent: HAEntity) -> HAState:
url = f"{config.ha_base_url}/api/states/{ent.entity_id}"
try:
resp = await client.get(url)
if resp.status_code != 200:
return HAState(label=ent.label, state=f"HTTP {resp.status_code}", ok=False)
data = resp.json()
unit = ent.unit or data.get("attributes", {}).get("unit_of_measurement", "")
return HAState(label=ent.label, state=str(data.get("state", "")), unit=unit)
except httpx.HTTPError:
return HAState(label=ent.label, state="erreur", ok=False)

View File

@@ -0,0 +1,86 @@
"""Météo via Open-Meteo (gratuit, sans clé API)."""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from config import config
API_URL = "https://api.open-meteo.com/v1/forecast"
# Codes WMO -> (libellé court FR, emoji). Suffisant pour un dashboard e-ink.
WMO = {
0: ("Dégagé", ""),
1: ("Peu nuageux", "🌤"),
2: ("Nuageux", ""),
3: ("Couvert", ""),
45: ("Brouillard", "🌫"),
48: ("Brouillard givrant", "🌫"),
51: ("Bruine légère", "🌦"),
53: ("Bruine", "🌦"),
55: ("Bruine forte", "🌦"),
61: ("Pluie faible", "🌧"),
63: ("Pluie", "🌧"),
65: ("Pluie forte", "🌧"),
71: ("Neige faible", "🌨"),
73: ("Neige", "🌨"),
75: ("Neige forte", "🌨"),
80: ("Averses", "🌦"),
81: ("Averses", "🌧"),
82: ("Fortes averses", ""),
95: ("Orage", ""),
96: ("Orage + grêle", ""),
99: ("Orage + grêle", ""),
}
@dataclass
class Weather:
ok: bool
error: str | None = None
temp: float | None = None
feels_like: float | None = None
label: str = ""
icon: str = ""
temp_min: float | None = None
temp_max: float | None = None
precip_prob: int | None = None
async def fetch_weather() -> Weather:
params = {
"latitude": config.weather_lat,
"longitude": config.weather_lon,
"current": "temperature_2m,apparent_temperature,weather_code",
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
"timezone": config.timezone,
"forecast_days": 1,
}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(API_URL, params=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
return Weather(ok=False, error=f"réseau: {exc}")
cur = data.get("current", {})
daily = data.get("daily", {})
code = int(cur.get("weather_code", -1))
label, icon = WMO.get(code, ("", "·"))
def _first(key: str):
vals = daily.get(key) or []
return vals[0] if vals else None
return Weather(
ok=True,
temp=cur.get("temperature_2m"),
feels_like=cur.get("apparent_temperature"),
label=label,
icon=icon,
temp_min=_first("temperature_2m_min"),
temp_max=_first("temperature_2m_max"),
precip_prob=_first("precipitation_probability_max"),
)

103
backend/render.py Normal file
View File

@@ -0,0 +1,103 @@
"""Construit le contexte, rend le HTML puis le capture en PNG niveaux de gris (1264x1680)."""
from __future__ import annotations
import asyncio
import io
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
from playwright.async_api import async_playwright
from config import config
from integrations import claude_usage, homeassistant, weather
TEMPLATES = Path(__file__).parent / "templates"
JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
_env = Environment(
loader=FileSystemLoader(str(TEMPLATES)),
autoescape=select_autoescape(["html"]),
)
def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]:
"""Transforme l'usage Claude en jauges affichables (la fonctionnalité phare)."""
out: list[dict] = []
if usage.five_hour:
out.append({
"name": "Session (5 h)",
"remaining": usage.five_hour.remaining_pct,
"resets_in": usage.five_hour.resets_in_human,
"extra": "",
})
if usage.seven_day:
extra = ""
if usage.seven_day_opus and usage.seven_day_opus.utilization:
extra = f"Opus {usage.seven_day_opus.remaining_pct:.0f}% rest."
out.append({
"name": "Hebdo (7 j)",
"remaining": usage.seven_day.remaining_pct,
"resets_in": usage.seven_day.resets_in_human,
"extra": extra,
})
return out
async def build_context() -> dict:
"""Récupère toutes les sources en parallèle et assemble le contexte du template."""
usage, wx, ha = await asyncio.gather(
claude_usage.fetch_usage(),
weather.fetch_weather(),
homeassistant.fetch_states(),
)
now = datetime.now(ZoneInfo(config.timezone))
return {
"width": config.width,
"height": config.height,
"time": now.strftime("%H:%M"),
"dow": JOURS[now.weekday()],
"date": f"{now.day} {MOIS[now.month - 1]}",
"weather": wx,
"claude": usage,
"gauges": _gauges(usage),
"ha_states": ha,
"updated": now.strftime("%H:%M"),
"stale": False,
}
def render_html(context: dict) -> str:
return _env.get_template("dashboard.html").render(**context)
async def render_png() -> bytes:
"""Rend le dashboard en PNG niveaux de gris prêt pour l'e-ink de la Kobo."""
context = await build_context()
html = render_html(context)
async with async_playwright() as p:
browser = await p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])
page = await browser.new_page(
viewport={"width": config.width, "height": config.height},
device_scale_factor=1,
)
await page.set_content(html, wait_until="networkidle")
png_bytes = await page.screenshot(type="png", clip={
"x": 0, "y": 0, "width": config.width, "height": config.height,
})
await browser.close()
# Conversion niveaux de gris (mode 'L') -> e-ink friendly, fichier plus léger.
img = Image.open(io.BytesIO(png_bytes)).convert("L")
out = io.BytesIO()
img.save(out, format="PNG", optimize=True)
return out.getvalue()

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
jinja2==3.1.5
playwright==1.49.1
pillow==11.1.0
python-dotenv==1.0.1

0
backend/static/.gitkeep Normal file
View File

View File

@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<style>
/* Dashboard e-ink 1264x1680 — noir & blanc, fort contraste, pas de dépendance couleur. */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--ink: #000;
--paper: #fff;
--muted: #555;
--line: #000;
--gauge-bg: #d9d9d9;
}
html, body {
width: {{ width }}px; height: {{ height }}px;
background: var(--paper); color: var(--ink);
font-family: "DejaVu Sans", "Noto Sans", Arial, sans-serif;
-webkit-font-smoothing: none;
}
body { padding: 48px 56px; display: flex; flex-direction: column; }
.section { margin-bottom: 40px; }
.rule { border: 0; border-top: 4px solid var(--line); margin: 0 0 28px; }
/* En-tête : heure + date */
header { display: flex; justify-content: space-between; align-items: flex-end; }
.clock { font-size: 150px; font-weight: 800; line-height: 0.9; letter-spacing: -4px; }
.date { font-size: 40px; font-weight: 600; text-align: right; text-transform: capitalize; }
.date .dow { font-size: 52px; font-weight: 800; }
/* Météo */
.weather { display: flex; align-items: center; gap: 36px; }
.weather .icon { font-size: 110px; line-height: 1; }
.weather .temp { font-size: 96px; font-weight: 800; }
.weather .meta { font-size: 36px; color: var(--muted); }
.weather .meta b { color: var(--ink); }
/* Titre de section */
.title { font-size: 34px; font-weight: 800; text-transform: uppercase;
letter-spacing: 3px; margin-bottom: 22px; }
/* Jauges Claude */
.gauge { margin-bottom: 34px; }
.gauge .row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.gauge .name { font-size: 40px; font-weight: 700; }
.gauge .pct { font-size: 64px; font-weight: 800; }
.gauge .pct small { font-size: 32px; font-weight: 600; }
.bar { position: relative; height: 46px; background: var(--gauge-bg);
border: 4px solid var(--ink); border-radius: 6px; overflow: hidden; }
.bar .fill { position: absolute; top: 0; left: 0; bottom: 0; background: var(--ink); }
.bar.low .fill { background: repeating-linear-gradient(45deg, #000 0 14px, #fff 14px 22px); }
.gauge .sub { font-size: 30px; color: var(--muted); margin-top: 10px; }
.err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); }
/* Grille Home Assistant */
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 40px; }
.ha-item { display: flex; justify-content: space-between; align-items: baseline;
border-bottom: 3px solid var(--ink); padding-bottom: 12px; }
.ha-item .k { font-size: 38px; font-weight: 600; }
.ha-item .v { font-size: 44px; font-weight: 800; }
footer { margin-top: auto; display: flex; justify-content: space-between;
font-size: 28px; color: var(--muted); padding-top: 20px; border-top: 3px solid var(--ink); }
.stale { font-weight: 800; color: var(--ink); }
</style>
</head>
<body>
<header class="section">
<div class="clock">{{ time }}</div>
<div class="date">
<div class="dow">{{ dow }}</div>
<div>{{ date }}</div>
</div>
</header>
<hr class="rule">
<div class="section weather">
{% if weather.ok %}
<div class="icon">{{ weather.icon }}</div>
<div>
<div class="temp">{{ weather.temp | round | int }}°</div>
<div class="meta">
{{ weather.label }} · ressenti <b>{{ weather.feels_like | round | int }}°</b>
</div>
</div>
<div class="meta" style="margin-left:auto; text-align:right;">
<div>min <b>{{ weather.temp_min | round | int }}°</b> / max <b>{{ weather.temp_max | round | int }}°</b></div>
{% if weather.precip_prob is not none %}<div>pluie <b>{{ weather.precip_prob }}%</b></div>{% endif %}
</div>
{% else %}
<div class="meta">Météo indisponible</div>
{% endif %}
</div>
<hr class="rule">
<div class="section">
<div class="title">Abonnement Claude{% if claude.ok %} · Max 5x{% endif %}</div>
{% if not claude.ok %}
<div class="err">⚠ {{ claude.error }}</div>
{% else %}
{% for g in gauges %}
<div class="gauge">
<div class="row">
<span class="name">{{ g.name }}</span>
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
</div>
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
<div class="fill" style="width: {{ g.remaining | round(1) }}%;"></div>
</div>
<div class="sub">reset dans {{ g.resets_in }}{% if g.extra %} · {{ g.extra }}{% endif %}</div>
</div>
{% endfor %}
{% if claude.burn_rate %}
<div class="sub" style="font-size:32px;">Burn rate : {{ claude.burn_rate | round | int }} tok/min</div>
{% endif %}
{% endif %}
</div>
{% if ha_states %}
<hr class="rule">
<div class="section">
<div class="title">Maison</div>
<div class="ha-grid">
{% for s in ha_states %}
<div class="ha-item"><span class="k">{{ s.label }}</span><span class="v">{{ s.display }}</span></div>
{% endfor %}
</div>
</div>
{% endif %}
<footer>
<span>Monitorink</span>
<span class="{% if stale %}stale{% endif %}">maj {{ updated }}{% if stale %} · DONNÉE PÉRIMÉE{% endif %}</span>
</footer>
</body>
</html>