Files
photoBooth/backend/api/system_api.py
T
admin fb35533b37
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
Dashboard: boutons restart services, JH-Photomaton et reboot Pi
2026-07-18 10:35:43 +02:00

255 lines
8.5 KiB
Python

"""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,
}
}