first commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""API de gestion des actions photobooth-app + mapping bouton."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/actions/photobooth")
|
||||
async def get_pb_actions(request: Request):
|
||||
"""Retourne les actions image de photobooth-app + le mapping bouton actuel."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
# Enrichit avec l'index
|
||||
for i, action in enumerate(actions):
|
||||
action["_index"] = i
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/actions/assets")
|
||||
async def get_assets(request: Request):
|
||||
"""Liste les cadres et fonds disponibles dans userdata."""
|
||||
pb = request.app.state.photobooth_service
|
||||
frames = await pb.list_userdata_frames()
|
||||
backgrounds = await pb.list_userdata_backgrounds()
|
||||
return {"frames": frames, "backgrounds": backgrounds}
|
||||
|
||||
|
||||
@router.put("/actions/mapping")
|
||||
async def update_button_mapping(
|
||||
request: Request,
|
||||
mapping: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Met à jour le mapping clics → actions.
|
||||
Body: { "1": {"label": "...", "photobooth_index": 0}, ... }
|
||||
"""
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
# Validation basique
|
||||
for k, v in mapping.items():
|
||||
if not isinstance(v, dict) or "photobooth_index" not in v:
|
||||
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
|
||||
|
||||
config_svc.save_button_actions(mapping)
|
||||
request.app.state.config.button_actions = mapping
|
||||
|
||||
# Met aussi à jour le button_service
|
||||
btn = request.app.state.button_service
|
||||
btn._btn_actions = mapping
|
||||
|
||||
logger.info("Mapping bouton mis à jour: %s", mapping)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.put("/actions/photobooth/{index}")
|
||||
async def update_pb_action(
|
||||
request: Request,
|
||||
index: int,
|
||||
updates: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.)
|
||||
updates peut contenir: countdown_capture, img_frame_file, img_background_file,
|
||||
remove_background, image_filter, name
|
||||
"""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
if index < 0 or index >= len(actions):
|
||||
return JSONResponse({"error": f"Index {index} invalide (max: {len(actions)-1})"}, status_code=400)
|
||||
|
||||
action = actions[index]
|
||||
|
||||
# Mise à jour des champs autorisés
|
||||
allowed_root = {"name"}
|
||||
allowed_processing = {"remove_background", "img_frame_file", "img_background_file",
|
||||
"image_filter", "fill_background_enable", "fill_background_color",
|
||||
"img_background_enable", "texts_enable"}
|
||||
allowed_jobcontrol = {"countdown_capture"}
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in allowed_root:
|
||||
action[key] = value
|
||||
elif key in allowed_processing:
|
||||
action.setdefault("processing", {})[key] = value
|
||||
elif key in allowed_jobcontrol:
|
||||
action.setdefault("jobcontrol", {})[key] = value
|
||||
|
||||
pb_config["actions"]["image"][index] = action
|
||||
await pb.write_pb_config(pb_config)
|
||||
|
||||
logger.info("Action %d mise à jour: %s", index, list(updates.keys()))
|
||||
return {"ok": True, "index": index, "action": action}
|
||||
|
||||
|
||||
@router.post("/actions/trigger/{index}")
|
||||
async def trigger_action(request: Request, index: int):
|
||||
"""Déclenche une action directement (test)."""
|
||||
pb = request.app.state.photobooth_service
|
||||
led = request.app.state.led_service
|
||||
btn = request.app.state.button_service
|
||||
btn.relay_off()
|
||||
try:
|
||||
result = await pb.trigger_image_action(index)
|
||||
return {"ok": True, "index": index, "result": result}
|
||||
except Exception as e:
|
||||
led.play("error")
|
||||
btn.relay_on()
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Routes du dashboard admin — authentification requise."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_templates = Jinja2Templates(
|
||||
directory=Path(__file__).parent.parent.parent / "frontend" / "templates"
|
||||
)
|
||||
|
||||
|
||||
def _is_auth(request: Request) -> bool:
|
||||
return request.session.get("authenticated") is True
|
||||
|
||||
|
||||
def _require_auth(request: Request):
|
||||
if not _is_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
return None
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if _is_auth(request):
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
return _templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
|
||||
|
||||
|
||||
@router.post("/admin/login")
|
||||
async def login(request: Request, password: str = Form(...)):
|
||||
cfg = request.app.state.config
|
||||
if password == cfg.app.admin_password:
|
||||
request.session["authenticated"] = True
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
return _templates.TemplateResponse(
|
||||
"admin/login.html",
|
||||
{"request": request, "error": "Mot de passe incorrect"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
|
||||
# ── Pages admin ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
sys_svc = request.app.state.system_service
|
||||
cfg = request.app.state.config
|
||||
led = request.app.state.led_service
|
||||
btn = request.app.state.button_service
|
||||
|
||||
stats = sys_svc.get_stats()
|
||||
services = sys_svc.get_services_status()
|
||||
|
||||
return _templates.TemplateResponse("admin/dashboard.html", {
|
||||
"request": request,
|
||||
"config": cfg,
|
||||
"stats": stats,
|
||||
"services": services,
|
||||
"led_effect": led.current_effect,
|
||||
"relay_state": btn.relay_state if btn else True,
|
||||
"print_mode": cfg.print.mode,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/gallery", response_class=HTMLResponse)
|
||||
async def admin_gallery(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
cfg = request.app.state.config
|
||||
return _templates.TemplateResponse("admin/gallery.html", {
|
||||
"request": request,
|
||||
"config": cfg,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/actions", response_class=HTMLResponse)
|
||||
async def admin_actions(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
return _templates.TemplateResponse("admin/actions.html", {
|
||||
"request": request,
|
||||
"config": cfg,
|
||||
"pb_actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/print", response_class=HTMLResponse)
|
||||
async def admin_print(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
printer_svc = request.app.state.printer_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
queue = await printer_svc.get_queue()
|
||||
printers = await printer_svc.get_printers_status()
|
||||
|
||||
return _templates.TemplateResponse("admin/print.html", {
|
||||
"request": request,
|
||||
"config": cfg,
|
||||
"queue": queue,
|
||||
"printers": printers,
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
"""API galerie admin — impression et suppression de photos."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/api/gallery/photos")
|
||||
async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
page_photos = photos[start:start + limit]
|
||||
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
|
||||
return {"photos": page_photos, "total": total, "page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit)}
|
||||
|
||||
|
||||
@router.post("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_print_photo(
|
||||
request: Request,
|
||||
photo_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
):
|
||||
"""Impression directe depuis la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
# Reconstruit le chemin du fichier depuis l'ID
|
||||
from pathlib import Path
|
||||
import re
|
||||
cfg = request.app.state.config
|
||||
|
||||
# photobooth-app identifiant → chemin fichier
|
||||
media_dir = cfg.photobooth.media_dir
|
||||
# Cherche le fichier correspondant
|
||||
filename = _find_file(media_dir, photo_id)
|
||||
if not filename:
|
||||
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
|
||||
if cfg.print.mode == "direct":
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry["id"], copies)
|
||||
await ws.broadcast({"type": "print_result", "result": result})
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
else:
|
||||
led.play("error")
|
||||
return result
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
return {"ok": True, "entry": entry, "mode": cfg.print.mode}
|
||||
|
||||
|
||||
@router.delete("/admin/api/gallery/{photo_id}")
|
||||
async def admin_delete_photo(request: Request, photo_id: str):
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
ok = await pb.delete_media(photo_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
|
||||
return {"ok": ok, "photo_id": photo_id}
|
||||
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
t = item.get("type", item.get("mediaitem_type", "image"))
|
||||
return str(t).lower() in ("image", "still", "photo")
|
||||
|
||||
|
||||
def _get_id(item: dict) -> str:
|
||||
return str(item.get("id", item.get("filename", item.get("uid", ""))))
|
||||
|
||||
|
||||
def _find_file(media_dir: str, photo_id: str):
|
||||
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
|
||||
from pathlib import Path
|
||||
base = Path(media_dir)
|
||||
if not base.exists():
|
||||
return None
|
||||
|
||||
# L'ID peut être le stem du filename
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
f = base / f"{photo_id}{ext}"
|
||||
if f.exists():
|
||||
return f
|
||||
# Cherche dans les sous-dossiers
|
||||
matches = list(base.rglob(f"{photo_id}{ext}"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
return None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Galerie publique — accessible sans authentification."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "frontend" / "templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""Page d'accueil → redirige vers la galerie publique."""
|
||||
return RedirectResponse(url="/gallery")
|
||||
|
||||
|
||||
@router.get("/gallery", response_class=HTMLResponse)
|
||||
async def gallery_page(request: Request):
|
||||
cfg = request.app.state.config
|
||||
if not cfg.gallery.public_enabled:
|
||||
return HTMLResponse("<h1>Galerie désactivée</h1>", status_code=403)
|
||||
return _templates.TemplateResponse("public/gallery.html", {"request": request, "config": cfg})
|
||||
|
||||
|
||||
@router.get("/api/gallery/photos")
|
||||
async def api_gallery_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos depuis photobooth-app (paginée)."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
|
||||
# Filtre sur les images uniquement
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
page_photos = photos[start:end]
|
||||
|
||||
# Enrichit avec les URLs
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
p["download_url"] = f"/api/gallery/download/{pid}"
|
||||
|
||||
return {
|
||||
"photos": page_photos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/gallery/download/{photo_id}")
|
||||
async def download_photo(request: Request, photo_id: str):
|
||||
"""Redirige vers le fichier full-res sur photobooth-app."""
|
||||
pb = request.app.state.photobooth_service
|
||||
return RedirectResponse(url=pb.media_url(photo_id))
|
||||
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
t = item.get("type", item.get("mediaitem_type", "image"))
|
||||
return str(t).lower() in ("image", "still", "photo")
|
||||
|
||||
|
||||
def _get_id(item: dict) -> str:
|
||||
return str(item.get("id", item.get("filename", item.get("uid", ""))))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""API de contrôle des LEDs WS2812b."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/leds/status")
|
||||
async def led_status(request: Request):
|
||||
led = request.app.state.led_service
|
||||
return {"effect": led.current_effect}
|
||||
|
||||
|
||||
@router.post("/leds/effect")
|
||||
async def set_effect(
|
||||
request: Request,
|
||||
effect: str = Query(..., description="idle | countdown | capture | captured | finished | printing | error | disabled | off"),
|
||||
):
|
||||
"""Force un effet LED (admin uniquement, pas de vérification auth ici — à protéger via Zoraxy)."""
|
||||
valid = ("idle", "countdown", "capture", "captured", "finished", "printing", "error", "disabled", "off")
|
||||
if effect not in valid:
|
||||
return JSONResponse({"error": f"Effet invalide. Valeurs: {valid}"}, status_code=400)
|
||||
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
led.play(effect)
|
||||
await ws.broadcast({"type": "led_effect", "effect": effect})
|
||||
return {"ok": True, "effect": effect}
|
||||
|
||||
|
||||
@router.post("/leds/color")
|
||||
async def set_color(
|
||||
request: Request,
|
||||
r: int = Query(default=0, ge=0, le=255),
|
||||
g: int = Query(default=0, ge=0, le=255),
|
||||
b: int = Query(default=0, ge=0, le=255),
|
||||
):
|
||||
"""Couleur fixe immédiate sur tout l'anneau."""
|
||||
led = request.app.state.led_service
|
||||
led.set_color(r, g, b)
|
||||
return {"ok": True, "color": [r, g, b]}
|
||||
|
||||
|
||||
@router.post("/leds/off")
|
||||
async def leds_off(request: Request):
|
||||
led = request.app.state.led_service
|
||||
led.play("off")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""API gestion de la file d'attente d'impression."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/print/request")
|
||||
async def print_request(
|
||||
request: Request,
|
||||
filename: str = Query(..., description="Chemin absolu du fichier à imprimer"),
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
):
|
||||
"""
|
||||
Reçoit la demande d'impression depuis photobooth-app
|
||||
(appelé par le share_command 'Demande d'impression').
|
||||
"""
|
||||
printer_svc = request.app.state.printer_service
|
||||
pb = request.app.state.photobooth_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
# Construit l'URL de miniature depuis le filename
|
||||
stem = Path(filename).stem
|
||||
thumb_url = pb.thumbnail_url(stem)
|
||||
|
||||
entry = await printer_svc.add_request(filename, thumb_url, copies)
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
|
||||
# Feedback LED si impression directe
|
||||
if request.app.state.config.print.mode == "direct":
|
||||
led.play("printing")
|
||||
|
||||
logger.info("Demande impression reçue: %s", filename)
|
||||
return {"ok": True, "id": entry["id"], "mode": request.app.state.config.print.mode}
|
||||
|
||||
|
||||
@router.get("/print/queue")
|
||||
async def get_queue(
|
||||
request: Request,
|
||||
status: str | None = Query(default=None),
|
||||
):
|
||||
"""Retourne la file d'attente d'impression."""
|
||||
printer_svc = request.app.state.printer_service
|
||||
queue = await printer_svc.get_queue(status)
|
||||
printers = await printer_svc.get_printers_status()
|
||||
return {"queue": queue, "printers": printers}
|
||||
|
||||
|
||||
@router.post("/print/execute/{entry_id}")
|
||||
async def execute_print(
|
||||
request: Request,
|
||||
entry_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
):
|
||||
"""Lance l'impression d'une entrée en attente (validation admin)."""
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry_id, copies)
|
||||
|
||||
await ws.broadcast({"type": "print_result", "entry_id": entry_id, "result": result})
|
||||
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
else:
|
||||
led.play("error")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/print/cancel/{entry_id}")
|
||||
async def cancel_print(request: Request, entry_id: str):
|
||||
"""Annule une demande en attente."""
|
||||
printer_svc = request.app.state.printer_service
|
||||
ws = request.app.state.ws_manager
|
||||
ok = await printer_svc.cancel(entry_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "print_cancelled", "entry_id": entry_id})
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@router.post("/print/cups/cancel/{printer_name}")
|
||||
async def cancel_cups_jobs(request: Request, printer_name: str):
|
||||
"""Annule tous les jobs CUPS d'une imprimante."""
|
||||
printer_svc = request.app.state.printer_service
|
||||
ok = await printer_svc.cancel_cups_jobs(printer_name)
|
||||
return {"ok": ok, "printer": printer_name}
|
||||
|
||||
|
||||
@router.get("/print/printers")
|
||||
async def get_printers(request: Request):
|
||||
"""Statut des imprimantes CUPS."""
|
||||
printer_svc = request.app.state.printer_service
|
||||
return await printer_svc.get_printers_status()
|
||||
|
||||
|
||||
@router.post("/print/mode")
|
||||
async def set_print_mode(request: Request, mode: str = Query(...)):
|
||||
"""Change le mode d'impression (direct | validation | gallery)."""
|
||||
if mode not in ("direct", "validation", "gallery"):
|
||||
return JSONResponse({"error": "Mode invalide"}, status_code=400)
|
||||
config_svc = request.app.state.config_service
|
||||
config_svc.save_print_mode(mode)
|
||||
request.app.state.config.print.mode = mode
|
||||
return {"ok": True, "mode": mode}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""API de surveillance des ressources système."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Webhooks reçus de photobooth-app via plugin_commander.
|
||||
|
||||
photobooth-app envoie un GET sur /api/webhook/photobooth?event_key=XXX&mediaitem_type=image
|
||||
pour chaque événement de capture.
|
||||
|
||||
Événements :
|
||||
counting → countdown en cours (LEDs remplissage vert)
|
||||
capture → flash photo (LEDs flash blanc)
|
||||
captured → photo prise (LEDs violet)
|
||||
finished → traitement terminé (LEDs bleu → retour idle + relay ON)
|
||||
start/stop → démarrage/arrêt photobooth-app
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Durée par défaut du countdown (en secondes) — peut être overridé par action
|
||||
DEFAULT_COUNTDOWN = 5.0
|
||||
|
||||
|
||||
@router.get("/webhook/photobooth")
|
||||
async def photobooth_webhook(
|
||||
request: Request,
|
||||
event_key: str = Query(default=""),
|
||||
mediaitem_type: str = Query(default=""),
|
||||
):
|
||||
led = request.app.state.led_service
|
||||
btn = request.app.state.button_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
|
||||
|
||||
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
|
||||
|
||||
match event_key:
|
||||
case "counting":
|
||||
# Countdown en cours — remplissage LED vert
|
||||
# On cherche la durée du countdown dans la config de l'action courante
|
||||
countdown_duration = _get_countdown_duration(request)
|
||||
led.play("countdown", countdown_duration=countdown_duration)
|
||||
|
||||
case "capture":
|
||||
# Flash photo
|
||||
led.play("capture")
|
||||
|
||||
case "captured":
|
||||
# Photo prise, traitement en cours
|
||||
led.play("captured")
|
||||
|
||||
case "finished":
|
||||
# Traitement terminé
|
||||
led.play("finished")
|
||||
# Réactive le relay après l'animation "finished" (durée ~2s gérée par le service LED)
|
||||
import asyncio
|
||||
asyncio.create_task(_delayed_relay_on(btn, 2.5))
|
||||
|
||||
case "start":
|
||||
led.play("idle")
|
||||
|
||||
case "stop":
|
||||
led.play("off")
|
||||
|
||||
case _:
|
||||
logger.debug("Événement inconnu: %s", event_key)
|
||||
|
||||
return {"ok": True, "event": event_key}
|
||||
|
||||
|
||||
async def _delayed_relay_on(btn, delay: float):
|
||||
import asyncio
|
||||
await asyncio.sleep(delay)
|
||||
if btn:
|
||||
btn.relay_on()
|
||||
|
||||
|
||||
def _get_countdown_duration(request: Request) -> float:
|
||||
"""
|
||||
Essaie de récupérer la durée du countdown depuis la config photobooth-app
|
||||
en lisant le dernier index d'action déclenché par le bouton.
|
||||
Retourne la valeur par défaut si indisponible.
|
||||
"""
|
||||
try:
|
||||
btn = request.app.state.button_service
|
||||
# On pourrait stocker le dernier index dans le button_service
|
||||
# Pour l'instant on retourne 5s par défaut
|
||||
return DEFAULT_COUNTDOWN
|
||||
except Exception:
|
||||
return DEFAULT_COUNTDOWN
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Service de gestion du bouton physique GPIO23 + relay GPIO12.
|
||||
|
||||
- Détection multi-clic (1 à 4 clics) avec timer
|
||||
- Détection long appui (>1500ms)
|
||||
- Contrôle du relay (désactive/active le bouton 12V)
|
||||
|
||||
Mode réel : gpiozero (Raspberry Pi)
|
||||
Mode mock : aucune action GPIO, simulation possible via API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from backend.services.config_service import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from gpiozero import Button, OutputDevice
|
||||
HAS_GPIO = True
|
||||
except (ImportError, Exception):
|
||||
HAS_GPIO = False
|
||||
logger.warning("gpiozero non disponible — mode mock bouton activé")
|
||||
|
||||
|
||||
class ButtonService:
|
||||
def __init__(self, config: Config, led_service, photobooth_service, ws_manager, loop: asyncio.AbstractEventLoop):
|
||||
self._cfg = config.button
|
||||
self._btn_actions = config.button_actions
|
||||
self._led = led_service
|
||||
self._pb = photobooth_service
|
||||
self._ws = ws_manager
|
||||
self._loop = loop
|
||||
|
||||
self._button = None
|
||||
self._relay = None
|
||||
|
||||
self._click_count = 0
|
||||
self._press_time: float = 0.0
|
||||
self._click_timer: threading.Timer | None = None
|
||||
self._long_press_fired = False
|
||||
self._relay_enabled = True
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self):
|
||||
if not HAS_GPIO:
|
||||
logger.info("Mode mock bouton — GPIO non disponible")
|
||||
return
|
||||
|
||||
try:
|
||||
self._relay = OutputDevice(
|
||||
self._cfg.relay_pin,
|
||||
active_high=True,
|
||||
initial_value=True,
|
||||
)
|
||||
self._button = Button(
|
||||
self._cfg.pin,
|
||||
pull_up=True,
|
||||
bounce_time=self._cfg.debounce_ms / 1000,
|
||||
)
|
||||
self._button.when_pressed = self._on_pressed
|
||||
self._button.when_released = self._on_released
|
||||
logger.info("Bouton GPIO%d, Relay GPIO%d initialisés", self._cfg.pin, self._cfg.relay_pin)
|
||||
except Exception as e:
|
||||
logger.error("Erreur init GPIO: %s", e)
|
||||
|
||||
def stop(self):
|
||||
if self._click_timer:
|
||||
self._click_timer.cancel()
|
||||
if self._button:
|
||||
try:
|
||||
self._button.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._relay:
|
||||
try:
|
||||
self._relay.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Relay ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def relay_on(self):
|
||||
"""Active le relay (bouton 12V allumé)."""
|
||||
self._relay_enabled = True
|
||||
if self._relay:
|
||||
self._relay.on()
|
||||
self._led.play("idle")
|
||||
logger.debug("Relay ON")
|
||||
|
||||
def relay_off(self):
|
||||
"""Désactive le relay (bouton 12V éteint pendant la capture)."""
|
||||
self._relay_enabled = False
|
||||
if self._relay:
|
||||
self._relay.off()
|
||||
self._led.play("disabled")
|
||||
logger.debug("Relay OFF")
|
||||
|
||||
# ── Simulation (pour mode mock / tests) ───────────────────────────────────
|
||||
|
||||
def simulate_click(self, count: int):
|
||||
asyncio.run_coroutine_threadsafe(self._handle_click(count), self._loop)
|
||||
|
||||
def simulate_long_press(self):
|
||||
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
|
||||
|
||||
# ── Callbacks GPIO ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_pressed(self):
|
||||
self._press_time = time.time()
|
||||
self._long_press_fired = False
|
||||
|
||||
# Lance un timer pour détecter le long appui
|
||||
if self._click_timer:
|
||||
self._click_timer.cancel()
|
||||
|
||||
long_ms = self._cfg.long_press_ms / 1000
|
||||
self._long_timer = threading.Timer(long_ms, self._on_long_press_timer)
|
||||
self._long_timer.start()
|
||||
|
||||
def _on_released(self):
|
||||
if hasattr(self, "_long_timer") and self._long_timer:
|
||||
self._long_timer.cancel()
|
||||
|
||||
if self._long_press_fired:
|
||||
return # Long press déjà traité
|
||||
|
||||
# Compte un clic
|
||||
with self._lock:
|
||||
self._click_count += 1
|
||||
count = self._click_count
|
||||
|
||||
if self._click_timer:
|
||||
self._click_timer.cancel()
|
||||
|
||||
if count >= self._cfg.max_clicks:
|
||||
# Dispatch immédiat si max atteint
|
||||
self._click_timer = threading.Timer(0.05, self._dispatch_clicks)
|
||||
else:
|
||||
# Attente pour éventuel prochain clic
|
||||
self._click_timer = threading.Timer(
|
||||
self._cfg.double_click_ms / 1000,
|
||||
self._dispatch_clicks,
|
||||
)
|
||||
self._click_timer.start()
|
||||
|
||||
def _on_long_press_timer(self):
|
||||
self._long_press_fired = True
|
||||
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
|
||||
|
||||
def _dispatch_clicks(self):
|
||||
with self._lock:
|
||||
count = self._click_count
|
||||
self._click_count = 0
|
||||
self._click_timer = None
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self._handle_click(count), self._loop)
|
||||
|
||||
# ── Handlers async ────────────────────────────────────────────────────────
|
||||
|
||||
async def _handle_click(self, count: int):
|
||||
if count < 1:
|
||||
return
|
||||
|
||||
count = min(count, self._cfg.max_clicks)
|
||||
logger.info("Bouton: %d clic(s)", count)
|
||||
|
||||
# Récupère l'index de l'action photobooth
|
||||
action_info = self._btn_actions.get(count) or self._btn_actions.get(str(count))
|
||||
if not action_info:
|
||||
logger.warning("Aucune action mappée pour %d clic(s)", count)
|
||||
return
|
||||
|
||||
pb_index = action_info.get("photobooth_index", 0)
|
||||
label = action_info.get("label", f"Action {count}")
|
||||
|
||||
await self._ws.broadcast({
|
||||
"type": "button_event",
|
||||
"clicks": count,
|
||||
"action": label,
|
||||
"photobooth_index": pb_index,
|
||||
})
|
||||
|
||||
# Désactive le relay + LED disabled
|
||||
self.relay_off()
|
||||
|
||||
# Déclenche l'action photobooth-app
|
||||
try:
|
||||
await self._pb.trigger_image_action(pb_index)
|
||||
except Exception as e:
|
||||
logger.error("Erreur déclenchement action %d: %s", pb_index, e)
|
||||
self._led.play("error")
|
||||
await asyncio.sleep(1)
|
||||
self.relay_on()
|
||||
|
||||
async def _handle_long_press(self):
|
||||
logger.info("Bouton: long appui")
|
||||
await self._ws.broadcast({"type": "button_event", "clicks": 0, "action": "long_press"})
|
||||
|
||||
if not self._cfg.print_enabled:
|
||||
return
|
||||
|
||||
# Déclenche la demande d'impression sur la dernière photo
|
||||
try:
|
||||
self._led.play("printing")
|
||||
await self._pb.trigger_share_latest(0)
|
||||
except Exception as e:
|
||||
logger.error("Erreur long press impression: %s", e)
|
||||
self._led.play("error")
|
||||
|
||||
@property
|
||||
def relay_state(self) -> bool:
|
||||
return self._relay_enabled
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Service de chargement et sauvegarde de la configuration YAML."""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
name: str = "JH Photomaton"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8090
|
||||
debug: bool = False
|
||||
secret_key: str = ""
|
||||
admin_password: str = "PhotoBooth2026!"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhotoboothConfig:
|
||||
base_url: str = "http://localhost:8083"
|
||||
data_dir: str = "/home/pi/photobooth-data"
|
||||
config_file: str = "/home/pi/.config/photobooth-app/config.json"
|
||||
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
|
||||
userdata_dir: str = "/home/pi/photobooth-data/userdata"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonConfig:
|
||||
pin: int = 23
|
||||
relay_pin: int = 12
|
||||
debounce_ms: int = 50
|
||||
double_click_ms: int = 400
|
||||
long_press_ms: int = 1500
|
||||
max_clicks: int = 4
|
||||
long_press_action: str = "print_last"
|
||||
print_enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDEffectConfig:
|
||||
color: list = field(default_factory=lambda: [0, 30, 80])
|
||||
mode: str = "solid"
|
||||
speed: float = 0.05
|
||||
flashes: int = 2
|
||||
flash_duration: float = 0.1
|
||||
duration: float = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDConfig:
|
||||
pin: int = 18
|
||||
count: int = 35
|
||||
brightness: int = 180
|
||||
freq_hz: int = 800000
|
||||
dma: int = 10
|
||||
strip_type: str = "WS2812"
|
||||
effects: dict = field(default_factory=dict)
|
||||
|
||||
def get_effect(self, name: str) -> LEDEffectConfig:
|
||||
raw = self.effects.get(name, {})
|
||||
return LEDEffectConfig(
|
||||
color=raw.get("color", [0, 30, 80]),
|
||||
mode=raw.get("mode", "solid"),
|
||||
speed=raw.get("speed", 0.05),
|
||||
flashes=raw.get("flashes", 2),
|
||||
flash_duration=raw.get("flash_duration", 0.1),
|
||||
duration=raw.get("duration", 2.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrintConfig:
|
||||
mode: str = "validation"
|
||||
script_path: str = "/home/pi/photobooth-data/script/script_print.sh"
|
||||
default_copies: int = 1
|
||||
printers: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryConfig:
|
||||
public_enabled: bool = True
|
||||
photos_per_page: int = 24
|
||||
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
app: AppConfig = field(default_factory=AppConfig)
|
||||
photobooth: PhotoboothConfig = field(default_factory=PhotoboothConfig)
|
||||
button: ButtonConfig = field(default_factory=ButtonConfig)
|
||||
leds: LEDConfig = field(default_factory=LEDConfig)
|
||||
print: PrintConfig = field(default_factory=PrintConfig)
|
||||
gallery: GalleryConfig = field(default_factory=GalleryConfig)
|
||||
button_actions: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class ConfigService:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._config: Config | None = None
|
||||
|
||||
def load(self) -> Config:
|
||||
if not self.path.exists():
|
||||
logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
|
||||
self._config = Config()
|
||||
return self._config
|
||||
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
|
||||
cfg = Config()
|
||||
|
||||
if "app" in raw:
|
||||
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if hasattr(AppConfig, k)})
|
||||
|
||||
if "photobooth" in raw:
|
||||
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if hasattr(PhotoboothConfig, k)})
|
||||
|
||||
if "button" in raw:
|
||||
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if hasattr(ButtonConfig, k)})
|
||||
|
||||
if "leds" in raw:
|
||||
led_raw = raw["leds"]
|
||||
cfg.leds = LEDConfig(
|
||||
pin=led_raw.get("pin", 18),
|
||||
count=led_raw.get("count", 35),
|
||||
brightness=led_raw.get("brightness", 180),
|
||||
freq_hz=led_raw.get("freq_hz", 800000),
|
||||
dma=led_raw.get("dma", 10),
|
||||
strip_type=led_raw.get("strip_type", "WS2812"),
|
||||
effects=led_raw.get("effects", {}),
|
||||
)
|
||||
|
||||
if "print" in raw:
|
||||
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if hasattr(PrintConfig, k)})
|
||||
|
||||
if "gallery" in raw:
|
||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
|
||||
|
||||
cfg.button_actions = raw.get("button_actions", {
|
||||
1: {"label": "Photo normale", "photobooth_index": 0},
|
||||
2: {"label": "Photo étoile", "photobooth_index": 1},
|
||||
3: {"label": "Photo cailloux", "photobooth_index": 2},
|
||||
4: {"label": "Photo soirée", "photobooth_index": 3},
|
||||
})
|
||||
|
||||
self._config = cfg
|
||||
logger.info("Configuration chargée depuis %s", self.path)
|
||||
return cfg
|
||||
|
||||
def save_button_actions(self, button_actions: dict):
|
||||
"""Met à jour uniquement la section button_actions dans settings.yaml."""
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
raw["button_actions"] = button_actions
|
||||
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
if self._config:
|
||||
self._config.button_actions = button_actions
|
||||
|
||||
def save_print_mode(self, mode: str):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("print", {})["mode"] = mode
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.print.mode = mode
|
||||
|
||||
@property
|
||||
def config(self) -> Config:
|
||||
if self._config is None:
|
||||
self.load()
|
||||
return self._config
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
|
||||
|
||||
Mode réel : rpi_ws281x (Raspberry Pi, doit tourner en root ou avec /dev/mem)
|
||||
Mode mock : log des opérations uniquement (développement)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from backend.services.config_service import Config, LEDConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
|
||||
HAS_WS281X = True
|
||||
except ImportError:
|
||||
HAS_WS281X = False
|
||||
logger.warning("rpi_ws281x non disponible — mode mock LED activé")
|
||||
|
||||
class WS_Color: # type: ignore
|
||||
def __init__(self, r: int, g: int, b: int):
|
||||
self.r, self.g, self.b = r, g, b
|
||||
def __repr__(self):
|
||||
return f"Color({self.r},{self.g},{self.b})"
|
||||
|
||||
class PixelStrip: # type: ignore
|
||||
def __init__(self, *args, **kwargs): pass
|
||||
def begin(self): pass
|
||||
def show(self): pass
|
||||
def setPixelColor(self, i, c): pass
|
||||
def numPixels(self): return 35
|
||||
def setBrightness(self, b): pass
|
||||
|
||||
|
||||
def _color(rgb: list[int]) -> WS_Color:
|
||||
return WS_Color(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def _lerp(a: int, b: int, t: float) -> int:
|
||||
return int(a + (b - a) * t)
|
||||
|
||||
|
||||
class LEDService:
|
||||
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
|
||||
|
||||
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
||||
"printing", "error", "disabled", "off")
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self._cfg: LEDConfig = config.leds
|
||||
self._strip: PixelStrip | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._cmd_event = threading.Event()
|
||||
self._current_effect: str = "idle"
|
||||
self._countdown_duration: float = 5.0
|
||||
self._lock = threading.Lock()
|
||||
self._on_change_cb: Callable | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self):
|
||||
if HAS_WS281X:
|
||||
self._strip = PixelStrip(
|
||||
self._cfg.count,
|
||||
self._cfg.pin,
|
||||
self._cfg.freq_hz,
|
||||
self._cfg.dma,
|
||||
False, # invert
|
||||
self._cfg.brightness,
|
||||
0, # channel
|
||||
)
|
||||
try:
|
||||
self._strip.begin()
|
||||
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
except Exception as e:
|
||||
logger.error("Erreur init strip LED: %s", e)
|
||||
self._strip = PixelStrip() # fallback mock
|
||||
else:
|
||||
self._strip = PixelStrip()
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="led-service")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
self._cmd_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
self._all_off()
|
||||
|
||||
def set_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
|
||||
def set_on_change(self, cb: Callable):
|
||||
self._on_change_cb = cb
|
||||
|
||||
# ── Commandes publiques (thread-safe) ────────────────────────────────────
|
||||
|
||||
def play(self, effect: str, countdown_duration: float = 5.0):
|
||||
"""Joue un effet nommé. Thread-safe."""
|
||||
with self._lock:
|
||||
self._current_effect = effect
|
||||
self._countdown_duration = countdown_duration
|
||||
self._cmd_event.set()
|
||||
logger.debug("LED effet: %s", effect)
|
||||
self._notify_change(effect)
|
||||
|
||||
def set_color(self, r: int, g: int, b: int):
|
||||
"""Couleur fixe immédiate."""
|
||||
self._fill(WS_Color(r, g, b))
|
||||
|
||||
# ── Thread principal ──────────────────────────────────────────────────────
|
||||
|
||||
def _run(self):
|
||||
effect_func = {
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
}
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
self._cmd_event.clear()
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cd_dur = self._countdown_duration
|
||||
|
||||
fn = effect_func.get(current, self._effect_idle)
|
||||
|
||||
try:
|
||||
if current in ("countdown",):
|
||||
fn(cd_dur)
|
||||
else:
|
||||
fn()
|
||||
except Exception as e:
|
||||
logger.error("Erreur effet LED '%s': %s", current, e)
|
||||
|
||||
# Si l'effet s'est terminé naturellement (ex: finished → retour idle)
|
||||
# on vérifie si un nouveau cmd est arrivé
|
||||
if not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
with self._lock:
|
||||
if self._current_effect == current:
|
||||
# Retour automatique à idle après effets ponctuels
|
||||
if current in ("capture", "captured", "finished", "error"):
|
||||
self._current_effect = "idle"
|
||||
self._cmd_event.wait(timeout=0.1)
|
||||
|
||||
# ── Effets ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _effect_idle(self):
|
||||
"""Respiration bleue douce."""
|
||||
cfg = self._cfg.get_effect("idle")
|
||||
c = cfg.color
|
||||
speed = cfg.speed
|
||||
n = self._cfg.count
|
||||
|
||||
step = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
|
||||
brightness = max(0.05, t)
|
||||
color = WS_Color(
|
||||
int(c[0] * brightness),
|
||||
int(c[1] * brightness),
|
||||
int(c[2] * brightness),
|
||||
)
|
||||
self._fill(color)
|
||||
time.sleep(speed)
|
||||
step += 1
|
||||
|
||||
def _effect_countdown(self, duration: float = 5.0):
|
||||
"""Remplissage progressif vert LED par LED."""
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
blank = WS_Color(0, 0, 0)
|
||||
color = WS_Color(c[0], c[1], c[2])
|
||||
|
||||
self._fill(blank)
|
||||
|
||||
t_start = time.time()
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
elapsed = time.time() - t_start
|
||||
ratio = min(elapsed / duration, 1.0)
|
||||
leds_on = int(ratio * n)
|
||||
|
||||
for i in range(n):
|
||||
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
||||
self._strip.show()
|
||||
|
||||
if ratio >= 1.0:
|
||||
break
|
||||
time.sleep(0.04)
|
||||
|
||||
def _effect_capture(self):
|
||||
"""Flash blanc."""
|
||||
cfg = self._cfg.get_effect("capture")
|
||||
white = WS_Color(255, 255, 255)
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(white)
|
||||
time.sleep(cfg.flash_duration)
|
||||
self._fill(off)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
def _effect_solid(self):
|
||||
"""Couleur pleine selon l'effet courant."""
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cfg = self._cfg.get_effect(current)
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
# Attente jusqu'à prochain cmd
|
||||
self._cmd_event.wait()
|
||||
|
||||
def _effect_finished(self):
|
||||
"""Bleu fixe pendant duration secondes, puis retour idle."""
|
||||
cfg = self._cfg.get_effect("finished")
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
self._cmd_event.wait(timeout=cfg.duration)
|
||||
|
||||
def _effect_spin(self):
|
||||
"""Rotation d'une traînée de LEDs."""
|
||||
cfg = self._cfg.get_effect("printing")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
speed = cfg.speed
|
||||
tail = 6
|
||||
|
||||
pos = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
for i in range(n):
|
||||
dist = (i - pos) % n
|
||||
if dist < tail:
|
||||
factor = (tail - dist) / tail
|
||||
self._strip.setPixelColor(
|
||||
i,
|
||||
WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
||||
self._strip.show()
|
||||
pos = (pos + 1) % n
|
||||
time.sleep(speed)
|
||||
|
||||
def _effect_error(self):
|
||||
"""Flash rouge."""
|
||||
cfg = self._cfg.get_effect("error")
|
||||
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(red)
|
||||
time.sleep(0.15)
|
||||
self._fill(off)
|
||||
time.sleep(0.15)
|
||||
|
||||
def _effect_off(self):
|
||||
self._fill(WS_Color(0, 0, 0))
|
||||
self._cmd_event.wait()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _fill(self, color: WS_Color):
|
||||
if not HAS_WS281X:
|
||||
logger.debug("LED mock fill: %s", color)
|
||||
return
|
||||
for i in range(self._cfg.count):
|
||||
self._strip.setPixelColor(i, color)
|
||||
self._strip.show()
|
||||
|
||||
def _all_off(self):
|
||||
try:
|
||||
self._fill(WS_Color(0, 0, 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify_change(self, effect: str):
|
||||
if self._on_change_cb and self._loop:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._on_change_cb({"type": "led_effect", "effect": effect}),
|
||||
self._loop,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_effect(self) -> str:
|
||||
with self._lock:
|
||||
return self._current_effect
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Client HTTP pour l'API de photobooth-app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.services.config_service import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PhotoboothService:
|
||||
def __init__(self, config: Config):
|
||||
self._cfg = config.photobooth
|
||||
self._base = self._cfg.base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(base_url=self._base, timeout=10.0)
|
||||
|
||||
async def trigger_image_action(self, index: int):
|
||||
"""Déclenche l'action image à l'index donné."""
|
||||
r = await self._client.get(f"/api/actions/image/{index}")
|
||||
r.raise_for_status()
|
||||
logger.info("Action image %d déclenchée", index)
|
||||
return r.json() if r.text else {}
|
||||
|
||||
async def trigger_share_latest(self, share_index: int = 0):
|
||||
"""Déclenche l'action de partage (impression) sur la dernière photo."""
|
||||
r = await self._client.get(f"/api/share/actions/latest/{share_index}")
|
||||
r.raise_for_status()
|
||||
logger.info("Share action %d déclenchée", share_index)
|
||||
return r.json() if r.text else {}
|
||||
|
||||
async def get_media_collection(self, limit: int = 200) -> list[dict]:
|
||||
"""Retourne la liste des photos de la galerie."""
|
||||
try:
|
||||
r = await self._client.get("/api/mediacollection/", params={"limit": limit})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# photobooth-app retourne soit une liste soit {"items": [...]}
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("items", data.get("media_items", []))
|
||||
except Exception as e:
|
||||
logger.error("Erreur récupération galerie: %s", e)
|
||||
return []
|
||||
|
||||
async def get_latest_media(self) -> dict | None:
|
||||
"""Retourne les infos de la dernière photo."""
|
||||
items = await self.get_media_collection(limit=1)
|
||||
return items[0] if items else None
|
||||
|
||||
async def delete_media(self, media_id: str) -> bool:
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
try:
|
||||
r = await self._client.delete(f"/api/mediacollection/{media_id}")
|
||||
return r.status_code in (200, 204)
|
||||
except Exception as e:
|
||||
logger.error("Erreur suppression %s: %s", media_id, e)
|
||||
return False
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
"""Vérifie que photobooth-app répond."""
|
||||
try:
|
||||
r = await self._client.get("/api/about", timeout=3.0)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def media_url(self, identifier: str) -> str:
|
||||
return f"{self._base}/media/full/{identifier}"
|
||||
|
||||
def thumbnail_url(self, identifier: str) -> str:
|
||||
return f"{self._base}/media/thumbnail/{identifier}"
|
||||
|
||||
async def read_pb_config(self) -> dict:
|
||||
"""Lit le fichier config.json de photobooth-app."""
|
||||
cfg_path = Path(self._cfg.config_file)
|
||||
if not cfg_path.exists():
|
||||
logger.warning("Config photobooth introuvable: %s", cfg_path)
|
||||
return {}
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
async def write_pb_config(self, config: dict):
|
||||
"""Écrit le fichier config.json de photobooth-app."""
|
||||
cfg_path = Path(self._cfg.config_file)
|
||||
# Backup avant écriture
|
||||
backup = cfg_path.with_suffix(f".json_backup_jh")
|
||||
if cfg_path.exists():
|
||||
import shutil
|
||||
shutil.copy2(cfg_path, backup)
|
||||
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
logger.info("Config photobooth-app mise à jour")
|
||||
|
||||
async def list_userdata_frames(self) -> list[str]:
|
||||
"""Liste les cadres PNG disponibles dans userdata."""
|
||||
return self._list_files(self._cfg.userdata_dir, "*.png", "frames")
|
||||
|
||||
async def list_userdata_backgrounds(self) -> list[str]:
|
||||
"""Liste les fonds disponibles dans userdata."""
|
||||
exts = ["*.jpg", "*.jpeg", "*.png"]
|
||||
files = []
|
||||
for ext in exts:
|
||||
files.extend(self._list_files(self._cfg.userdata_dir, ext, "backgrounds"))
|
||||
return sorted(set(files))
|
||||
|
||||
def _list_files(self, base: str, pattern: str, subdir_hint: str) -> list[str]:
|
||||
base_path = Path(base)
|
||||
if not base_path.exists():
|
||||
return []
|
||||
results = []
|
||||
for f in base_path.rglob(pattern):
|
||||
if subdir_hint in f.parts or True: # liste tout
|
||||
# Chemin relatif depuis data_dir pour passer à photobooth-app
|
||||
try:
|
||||
rel = f.relative_to(Path(self._cfg.data_dir))
|
||||
results.append(str(rel))
|
||||
except ValueError:
|
||||
results.append(str(f))
|
||||
return sorted(results)
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Service d'impression — file d'attente SQLite + appel script_print.sh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from backend.services.config_service import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"]
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS print_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
thumb_url TEXT,
|
||||
copies INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'pending',
|
||||
printer TEXT,
|
||||
requested_at REAL,
|
||||
processed_at REAL,
|
||||
error_msg TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class PrinterService:
|
||||
def __init__(self, config: Config):
|
||||
self._cfg = config.print
|
||||
self._db_path: Path | None = None
|
||||
self._db: aiosqlite.Connection | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def init_db(self, db_path: Path):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db_path = db_path
|
||||
self._db = await aiosqlite.connect(str(db_path))
|
||||
self._db.row_factory = aiosqlite.Row
|
||||
await self._db.execute(CREATE_SQL)
|
||||
await self._db.commit()
|
||||
logger.info("Base print_queue initialisée: %s", db_path)
|
||||
|
||||
async def close(self):
|
||||
if self._db:
|
||||
await self._db.close()
|
||||
|
||||
# ── File d'attente ────────────────────────────────────────────────────────
|
||||
|
||||
async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> dict:
|
||||
"""Ajoute une demande d'impression dans la file. Retourne l'entrée créée."""
|
||||
entry_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
|
||||
await self._db.execute(
|
||||
"INSERT INTO print_queue (id, filename, thumb_url, copies, status, requested_at) "
|
||||
"VALUES (?, ?, ?, ?, 'pending', ?)",
|
||||
(entry_id, filename, thumb_url, copies, now),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
"filename": filename,
|
||||
"thumb_url": thumb_url,
|
||||
"copies": copies,
|
||||
"status": "pending",
|
||||
"requested_at": now,
|
||||
}
|
||||
|
||||
# Mode direct : impression immédiate sans validation
|
||||
if self._cfg.mode == "direct":
|
||||
asyncio.create_task(self.execute_print(entry_id, copies))
|
||||
|
||||
logger.info("Demande d'impression ajoutée: %s (%s)", entry_id, filename)
|
||||
return entry
|
||||
|
||||
async def get_queue(self, status: str | None = None) -> list[dict]:
|
||||
"""Liste les entrées de la file d'attente."""
|
||||
if status:
|
||||
cursor = await self._db.execute(
|
||||
"SELECT * FROM print_queue WHERE status = ? ORDER BY requested_at DESC",
|
||||
(status,),
|
||||
)
|
||||
else:
|
||||
cursor = await self._db.execute(
|
||||
"SELECT * FROM print_queue ORDER BY requested_at DESC LIMIT 100"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_pending(self) -> list[dict]:
|
||||
return await self.get_queue("pending")
|
||||
|
||||
async def cancel(self, entry_id: str) -> bool:
|
||||
async with self._lock:
|
||||
cursor = await self._db.execute(
|
||||
"SELECT status FROM print_queue WHERE id = ?", (entry_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or row["status"] not in ("pending",):
|
||||
return False
|
||||
await self._db.execute(
|
||||
"UPDATE print_queue SET status='cancelled', processed_at=? WHERE id=?",
|
||||
(time.time(), entry_id),
|
||||
)
|
||||
await self._db.commit()
|
||||
return True
|
||||
|
||||
# ── Impression ────────────────────────────────────────────────────────────
|
||||
|
||||
async def execute_print(self, entry_id: str, copies: int = 1, printer: str = "") -> dict:
|
||||
"""Lance l'impression via script_print.sh."""
|
||||
async with self._lock:
|
||||
cursor = await self._db.execute(
|
||||
"SELECT * FROM print_queue WHERE id = ?", (entry_id,)
|
||||
)
|
||||
entry = await cursor.fetchone()
|
||||
if not entry:
|
||||
return {"success": False, "error": "Entrée introuvable"}
|
||||
if entry["status"] not in ("pending",):
|
||||
return {"success": False, "error": f"Statut incompatible: {entry['status']}"}
|
||||
|
||||
await self._db.execute(
|
||||
"UPDATE print_queue SET status='printing', copies=? WHERE id=?",
|
||||
(copies, entry_id),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
filename = entry["filename"]
|
||||
script = self._cfg.script_path
|
||||
|
||||
# Appel async du script d'impression
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self._run_print_script, script, filename, copies
|
||||
)
|
||||
except Exception as e:
|
||||
result = {"success": False, "error": str(e), "printer": ""}
|
||||
|
||||
now = time.time()
|
||||
if result["success"]:
|
||||
await self._db.execute(
|
||||
"UPDATE print_queue SET status='done', printer=?, processed_at=? WHERE id=?",
|
||||
(result.get("printer", ""), now, entry_id),
|
||||
)
|
||||
else:
|
||||
await self._db.execute(
|
||||
"UPDATE print_queue SET status='error', error_msg=?, processed_at=? WHERE id=?",
|
||||
(result.get("error", ""), now, entry_id),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
logger.info("Impression %s: %s", entry_id, "OK" if result["success"] else result.get("error"))
|
||||
return result
|
||||
|
||||
def _run_print_script(self, script: str, filename: str, copies: int) -> dict:
|
||||
"""Appelle script_print.sh de façon synchrone."""
|
||||
if not Path(script).exists():
|
||||
return {"success": False, "error": f"Script introuvable: {script}"}
|
||||
if not Path(filename).exists():
|
||||
return {"success": False, "error": f"Fichier introuvable: {filename}"}
|
||||
|
||||
cmd = ["/bin/bash", script, filename, "image", "default", str(copies)]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
stdout = proc.stdout.strip()
|
||||
if proc.returncode == 0 and "PRINTED:" in stdout:
|
||||
parts = stdout.split(":")
|
||||
printer = parts[1] if len(parts) > 1 else ""
|
||||
return {"success": True, "printer": printer, "output": stdout}
|
||||
else:
|
||||
return {"success": False, "error": proc.stderr.strip() or stdout, "printer": ""}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Timeout impression (60s)"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
|
||||
|
||||
async def get_printers_status(self) -> list[dict]:
|
||||
"""Retourne le statut des imprimantes CUPS configurées."""
|
||||
statuses = []
|
||||
for p in self._cfg.printers:
|
||||
status = await asyncio.to_thread(self._get_printer_status, p["name"])
|
||||
statuses.append({
|
||||
"name": p["name"],
|
||||
"label": p["label"],
|
||||
**status,
|
||||
})
|
||||
return statuses
|
||||
|
||||
def _get_printer_status(self, printer_name: str) -> dict:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lpstat", "-p", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
output = result.stdout.lower()
|
||||
if "idle" in output:
|
||||
state = "idle"
|
||||
elif "printing" in output or "processing" in output:
|
||||
state = "printing"
|
||||
elif "disabled" in output:
|
||||
state = "disabled"
|
||||
elif "not found" in output or result.returncode != 0:
|
||||
state = "offline"
|
||||
else:
|
||||
state = "unknown"
|
||||
|
||||
# Compte les jobs en attente
|
||||
jobs_result = subprocess.run(
|
||||
["lpstat", "-o", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l])
|
||||
|
||||
return {"state": state, "jobs": jobs}
|
||||
except Exception as e:
|
||||
return {"state": "error", "jobs": 0, "error": str(e)}
|
||||
|
||||
async def cancel_cups_jobs(self, printer_name: str) -> bool:
|
||||
"""Annule tous les jobs CUPS pour une imprimante."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cancel", "-a", printer_name],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur cancel CUPS: %s", e)
|
||||
return False
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Service de surveillance des ressources système du Raspberry Pi."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import psutil
|
||||
HAS_PSUTIL = True
|
||||
except ImportError:
|
||||
HAS_PSUTIL = False
|
||||
logging.warning("psutil non disponible — stats système limitées")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemService:
|
||||
def get_stats(self) -> dict:
|
||||
stats: dict = {}
|
||||
|
||||
if not HAS_PSUTIL:
|
||||
return {"error": "psutil non installé"}
|
||||
|
||||
# CPU
|
||||
stats["cpu_percent"] = psutil.cpu_percent(interval=None)
|
||||
|
||||
# RAM
|
||||
mem = psutil.virtual_memory()
|
||||
stats["ram_total_mb"] = round(mem.total / 1024 / 1024)
|
||||
stats["ram_used_mb"] = round(mem.used / 1024 / 1024)
|
||||
stats["ram_percent"] = mem.percent
|
||||
stats["ram_available_mb"] = round(mem.available / 1024 / 1024)
|
||||
|
||||
# Disque (racine)
|
||||
disk = psutil.disk_usage("/")
|
||||
stats["disk_total_gb"] = round(disk.total / 1024 ** 3, 1)
|
||||
stats["disk_used_gb"] = round(disk.used / 1024 ** 3, 1)
|
||||
stats["disk_percent"] = disk.percent
|
||||
|
||||
# Température CPU (Raspberry Pi)
|
||||
stats["cpu_temp"] = self._get_cpu_temp()
|
||||
|
||||
# Uptime
|
||||
import time
|
||||
boot_time = psutil.boot_time()
|
||||
uptime_sec = int(time.time() - boot_time)
|
||||
stats["uptime"] = self._format_uptime(uptime_sec)
|
||||
|
||||
return stats
|
||||
|
||||
def _get_cpu_temp(self) -> float | None:
|
||||
# Méthode 1 : fichier thermal du Pi
|
||||
try:
|
||||
temp_path = Path("/sys/class/thermal/thermal_zone0/temp")
|
||||
if temp_path.exists():
|
||||
return round(int(temp_path.read_text().strip()) / 1000, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Méthode 2 : vcgencmd (Pi OS)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["vcgencmd", "measure_temp"],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# output: "temp=47.0'C"
|
||||
val = result.stdout.strip().replace("temp=", "").replace("'C", "")
|
||||
return float(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Méthode 3 : psutil sensors (si disponible)
|
||||
if hasattr(psutil, "sensors_temperatures"):
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
if temps:
|
||||
first = next(iter(temps.values()))
|
||||
if first:
|
||||
return round(first[0].current, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _format_uptime(self, seconds: int) -> str:
|
||||
days = seconds // 86400
|
||||
hours = (seconds % 86400) // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
if days > 0:
|
||||
return f"{days}j {hours:02d}h {minutes:02d}m"
|
||||
return f"{hours:02d}h {minutes:02d}m"
|
||||
|
||||
def get_services_status(self) -> list[dict]:
|
||||
"""Vérifie le statut des services systemd utiles."""
|
||||
services = [
|
||||
("photobooth-app", "Photobooth App"),
|
||||
("jh-photomaton", "JH Photomaton"),
|
||||
("cups", "CUPS (Impression)"),
|
||||
]
|
||||
result = []
|
||||
for svc_name, label in services:
|
||||
active = self._is_service_active(svc_name)
|
||||
result.append({"name": svc_name, "label": label, "active": active})
|
||||
return result
|
||||
|
||||
def _is_service_active(self, service: str) -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["systemctl", "is-active", service],
|
||||
capture_output=True, text=True, timeout=3
|
||||
)
|
||||
return r.stdout.strip() == "active"
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Authentification admin — session cookie simple."""
|
||||
|
||||
from functools import wraps
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
|
||||
def is_authenticated(request: Request) -> bool:
|
||||
return request.session.get("authenticated") is True
|
||||
|
||||
|
||||
def require_auth(func):
|
||||
"""Décorateur pour les routes admin."""
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
if not is_authenticated(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
return await func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Gestionnaire de connexions WebSocket."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WSManager:
|
||||
def __init__(self):
|
||||
self.active: list[WebSocket] = []
|
||||
|
||||
async def connect(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.active.append(ws)
|
||||
logger.debug("WS connecté (%d total)", len(self.active))
|
||||
|
||||
def disconnect(self, ws: WebSocket):
|
||||
self.active.discard(ws) if hasattr(self.active, "discard") else None
|
||||
if ws in self.active:
|
||||
self.active.remove(ws)
|
||||
logger.debug("WS déconnecté (%d restants)", len(self.active))
|
||||
|
||||
async def broadcast(self, data: dict[str, Any]):
|
||||
if not self.active:
|
||||
return
|
||||
msg = json.dumps(data, default=str)
|
||||
dead = []
|
||||
for ws in self.active:
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(ws)
|
||||
|
||||
async def send(self, ws: WebSocket, data: dict[str, Any]):
|
||||
try:
|
||||
await ws.send_text(json.dumps(data, default=str))
|
||||
except Exception as e:
|
||||
logger.debug("Erreur envoi WS: %s", e)
|
||||
self.disconnect(ws)
|
||||
Reference in New Issue
Block a user