Backend Monitorink: serveur PNG (Claude usage + météo + HA)
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ──
|
||||
|
||||
# Token Claude longue durée, généré par `claude setup-token` sur le homelab.
|
||||
MONITORINK_CLAUDE_TOKEN=sk-ant-oat01-xxxxxxxx
|
||||
# User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/<version>)
|
||||
MONITORINK_CLAUDE_UA=claude-code/2.1.172
|
||||
# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1
|
||||
MONITORINK_CCUSAGE=0
|
||||
|
||||
# Affichage
|
||||
MONITORINK_TZ=Europe/Paris
|
||||
MONITORINK_WIDTH=1264
|
||||
MONITORINK_HEIGHT=1680
|
||||
MONITORINK_CACHE_TTL=120
|
||||
|
||||
# Météo (Open-Meteo, sans clé) — coordonnées
|
||||
MONITORINK_LAT=48.8566
|
||||
MONITORINK_LON=2.3522
|
||||
|
||||
# Home Assistant (optionnel) — laisser vide pour désactiver
|
||||
MONITORINK_HA_URL=http://homeassistant.local:8123
|
||||
MONITORINK_HA_TOKEN=
|
||||
# Entités : "entity_id|Libellé|unité" séparées par des virgules
|
||||
MONITORINK_HA_ENTITIES=sensor.salon_temperature|Salon|°C, binary_sensor.porte_entree|Porte, person.jerem|Jerem
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Secrets & config locale
|
||||
.env
|
||||
*.local
|
||||
backend/.credentials.json
|
||||
backend/.credentials.json.*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Rendu / cache
|
||||
*.png
|
||||
!backend/static/*.png
|
||||
backend/out/
|
||||
last_usage.json
|
||||
|
||||
# Binaires Kobo (téléchargés depuis trmnl-kobo, pas versionnés)
|
||||
kobo/bin/*
|
||||
!kobo/bin/.gitkeep
|
||||
|
||||
# OS / éditeurs
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal 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
49
backend/app.py
Normal 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
81
backend/config.py
Normal 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()
|
||||
0
backend/integrations/__init__.py
Normal file
0
backend/integrations/__init__.py
Normal file
133
backend/integrations/claude_usage.py
Normal file
133
backend/integrations/claude_usage.py
Normal 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,
|
||||
)
|
||||
55
backend/integrations/homeassistant.py
Normal file
55
backend/integrations/homeassistant.py
Normal 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)
|
||||
86
backend/integrations/weather.py
Normal file
86
backend/integrations/weather.py
Normal 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
103
backend/render.py
Normal 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
7
backend/requirements.txt
Normal 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
0
backend/static/.gitkeep
Normal file
138
backend/templates/dashboard.html
Normal file
138
backend/templates/dashboard.html
Normal 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>
|
||||
44
dev/probe_usage.py
Normal file
44
dev/probe_usage.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink.
|
||||
|
||||
Usage (sur le homelab, après `claude setup-token`) :
|
||||
MONITORINK_CLAUDE_TOKEN="sk-ant-oat01-..." python3 dev/probe_usage.py
|
||||
|
||||
Affiche la structure JSON brute renvoyée par l'API (uniquement des % d'usage, aucun secret)
|
||||
+ l'interprétation que Monitorink en fait. Sortie partageable sans risque.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
URL = "https://api.anthropic.com/api/oauth/usage"
|
||||
token = os.environ.get("MONITORINK_CLAUDE_TOKEN", "").strip()
|
||||
if not token:
|
||||
sys.exit("MONITORINK_CLAUDE_TOKEN manquant (export-le ou préfixe la commande)")
|
||||
|
||||
req = urllib.request.Request(URL, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"User-Agent": "claude-code/2.1.172",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.load(r)
|
||||
print("HTTP", r.status)
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"HTTP {e.code}: {e.read().decode()[:300]}")
|
||||
|
||||
print("=== RÉPONSE BRUTE ===")
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
print("\n=== INTERPRÉTATION MONITORINK ===")
|
||||
for key in ("five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"):
|
||||
w = data.get(key)
|
||||
if isinstance(w, dict):
|
||||
util = w.get("utilization", 0)
|
||||
print(f"{key:18s}: {100 - util:.0f}% restant (util={util}, reset={w.get('resets_at')})")
|
||||
else:
|
||||
print(f"{key:18s}: {w!r}")
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
monitorink:
|
||||
build: ./backend
|
||||
image: monitorink:latest
|
||||
container_name: monitorink
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
# Optionnel : burn rate via ccusage (lecture seule des logs Claude Code du homelab).
|
||||
# Décommenter et passer MONITORINK_CCUSAGE=1 si voulu.
|
||||
# volumes:
|
||||
# - /home/jerem/.claude/projects:/root/.claude/projects:ro
|
||||
networks:
|
||||
- nestorr
|
||||
labels:
|
||||
caddy: http://monitorink.homelab.nestor-server.fr
|
||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8080/health').status==200 else sys.exit(1)"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
nestorr:
|
||||
external: true
|
||||
0
kobo/bin/.gitkeep
Normal file
0
kobo/bin/.gitkeep
Normal file
Reference in New Issue
Block a user