"""API de surveillance des ressources système.""" import asyncio import logging from typing import Optional from fastapi import APIRouter, Request, Body from fastapi.responses import JSONResponse from pydantic import BaseModel logger = logging.getLogger(__name__) router = APIRouter() # Services autorisés pour le restart individuel (jh-photomaton a son propre endpoint) _ALLOWED_SERVICES = { "photobooth-app", "photobooth-kiosk", "zoraxy", "hostapd", "dnsmasq", "cups", } @router.get("/system/stats") async def system_stats(request: Request): """Ressources système en temps réel.""" sys_svc = request.app.state.system_service return sys_svc.get_stats() @router.get("/system/services") async def system_services(request: Request): """Statut des services systemd.""" sys_svc = request.app.state.system_service return sys_svc.get_services_status() @router.get("/system/photobooth") async def photobooth_status(request: Request): """Vérifie si photobooth-app répond.""" pb = request.app.state.photobooth_service alive = await pb.is_alive() return {"alive": alive, "url": request.app.state.config.photobooth.base_url} @router.post("/system/button/simulate") async def simulate_button(request: Request, clicks: int = 1): """Simule un appui bouton (dev/test uniquement).""" btn = request.app.state.button_service if clicks == 0: btn.simulate_long_press() else: btn.simulate_click(clicks) return {"ok": True, "simulated_clicks": clicks} @router.post("/system/relay") async def control_relay(request: Request, state: str = "on"): """Force le relay ON/OFF.""" btn = request.app.state.button_service if state == "on": btn.relay_on() else: btn.relay_off() return {"ok": True, "relay": state} @router.get("/system/ui/delete-button") async def get_delete_button(request: Request): """Retourne l'état actuel du bouton Supprimer de photobooth-app.""" visible = request.app.state.config.photobooth.show_delete_button return {"visible": visible} @router.post("/system/ui/delete-button") async def set_delete_button(request: Request, visible: bool): """Active ou désactive le bouton Supprimer dans l'UI de review photobooth-app. Écrit userdata/private.css puis redémarre photobooth-app pour que le CSS soit pris en compte. """ pb = request.app.state.photobooth_service config_svc = request.app.state.config_service ok = await pb.set_delete_button_visible(visible) if ok: config_svc.save_show_delete_button(visible) # Redémarre photobooth-app en arrière-plan pour recharger le CSS asyncio.create_task(_restart_photobooth()) return {"ok": ok, "visible": visible} async def _restart_photobooth(): await asyncio.sleep(0.5) proc = await asyncio.create_subprocess_exec( "sudo", "systemctl", "restart", "photobooth-app.service", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() logger.info("photobooth-app redémarré") @router.post("/system/restart-photobooth") async def restart_photobooth(request: Request): """Redémarre photobooth-app.service pour recharger la config (actions, etc.).""" asyncio.create_task(_restart_photobooth()) return {"ok": True, "message": "Redémarrage de photobooth-app en cours…"} # ── Gestion services & système ──────────────────────────────────────────────── @router.get("/system/ping") async def ping(): """Endpoint de health-check — utilisé par le polling de reconnexion côté client.""" return {"ok": True} @router.post("/system/service/{name}/restart") async def restart_service(name: str): """Redémarre un service systemd autorisé.""" if name not in _ALLOWED_SERVICES: return JSONResponse({"ok": False, "error": f"Service '{name}' non autorisé"}, status_code=403) asyncio.create_task(_run_systemctl_restart(name)) return {"ok": True, "message": f"Redémarrage de {name} en cours…"} async def _run_systemctl_restart(name: str): proc = await asyncio.create_subprocess_exec( "sudo", "systemctl", "restart", f"{name}.service", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() logger.info("%s redémarré (exit %d)", name, proc.returncode) @router.post("/system/restart-self") async def restart_self(): """Redémarre le service jh-photomaton lui-même (avec délai pour que la réponse parte d'abord).""" asyncio.create_task(_restart_self_delayed()) return {"ok": True, "message": "Redémarrage de JH-Photomaton en cours…"} async def _restart_self_delayed(): await asyncio.sleep(1.5) proc = await asyncio.create_subprocess_exec( "sudo", "systemctl", "restart", "jh-photomaton.service", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() @router.post("/system/reboot") async def reboot_pi(): """Redémarre le Raspberry Pi (avec délai pour que la réponse parte d'abord).""" asyncio.create_task(_reboot_delayed()) return {"ok": True, "message": "Redémarrage du Pi en cours…"} async def _reboot_delayed(): await asyncio.sleep(2) proc = await asyncio.create_subprocess_exec( "sudo", "reboot", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() @router.post("/system/screen/refresh") async def screen_refresh(request: Request): """Envoie F5 à l'écran HDMI connecté (Chromium kiosque Wayland/X11).""" import subprocess, os, asyncio # Cherche le display Wayland du user pi (UID 1000) # Essaie wayland-0 puis wayland-1 xdg_runtime = "/run/user/1000" cmd_wtype = f"WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5" cmd_x11 = f"DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority xdotool key F5" for cmd in [cmd_wtype, cmd_x11]: try: proc = await asyncio.create_subprocess_shell( f"su -s /bin/bash pi -c '{cmd}'", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) _, stderr = await asyncio.wait_for(proc.communicate(), timeout=3) if proc.returncode == 0: logger.info("Screen refresh OK via: %s", cmd.split()[0]) return {"ok": True, "method": cmd.split()[0]} except Exception as e: logger.debug("Screen refresh cmd failed (%s): %s", cmd.split()[0], e) return JSONResponse({"ok": False, "error": "Aucune méthode disponible (wtype/xdotool)"}, status_code=500) class ButtonConfigUpdate(BaseModel): double_click_ms: Optional[int] = None long_press_ms: Optional[int] = None debounce_ms: Optional[int] = None max_clicks: Optional[int] = None print_enabled: Optional[bool] = None save: bool = False # True = persiste dans settings.yaml @router.get("/system/button/config") async def get_button_config(request: Request): """Retourne la configuration actuelle du bouton (timings).""" cfg = request.app.state.config.button return { "double_click_ms": cfg.double_click_ms, "long_press_ms": cfg.long_press_ms, "debounce_ms": cfg.debounce_ms, "max_clicks": cfg.max_clicks, "print_enabled": cfg.print_enabled, } @router.put("/system/button/config") async def update_button_config(request: Request, payload: ButtonConfigUpdate): """Met à jour les timings du bouton (à chaud + optionnellement sauvegardé).""" btn = request.app.state.button_service config_svc = request.app.state.config_service update = {k: v for k, v in payload.model_dump(exclude={"save"}).items() if v is not None} # Application immédiate (sans redémarrage) btn.update_timings( double_click_ms=payload.double_click_ms, long_press_ms=payload.long_press_ms, debounce_ms=payload.debounce_ms, max_clicks=payload.max_clicks, ) # Persistance optionnelle if payload.save and update: config_svc.save_button_config(update) cfg = request.app.state.config.button return { "ok": True, "saved": payload.save, "current": { "double_click_ms": cfg.double_click_ms, "long_press_ms": cfg.long_press_ms, "debounce_ms": cfg.debounce_ms, "max_clicks": cfg.max_clicks, "print_enabled": cfg.print_enabled, } }