diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index 2aeada2..c56c4be 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -110,6 +110,19 @@ async def admin_actions(request: Request): }) +@router.get("/admin/settings", response_class=HTMLResponse) +async def admin_settings(request: Request): + redirect = _require_auth(request) + if redirect: + return redirect + cfg = request.app.state.config + return _templates.TemplateResponse("admin/settings.html", { + "request": request, + "config": cfg, + "button": cfg.button, + }) + + @router.get("/admin/print", response_class=HTMLResponse) async def admin_print(request: Request): redirect = _require_auth(request) diff --git a/backend/api/admin_gallery_api.py b/backend/api/admin_gallery_api.py index f112728..4c555ad 100644 --- a/backend/api/admin_gallery_api.py +++ b/backend/api/admin_gallery_api.py @@ -1,98 +1,15 @@ """API galerie admin — impression et suppression de photos.""" import logging +from pathlib import Path from fastapi import APIRouter, Request, Query -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.responses import JSONResponse 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} - +# ── Helpers ─────────────────────────────────────────────────────────────────── def _is_image(item: dict) -> bool: t = item.get("type", item.get("mediaitem_type", "image")) @@ -103,20 +20,179 @@ 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): +def _find_file(media_dir: str, photo_id: str) -> Path | None: """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 + + +def _require_auth(request: Request): + return request.session.get("authenticated") is True + + +# ── Photos ──────────────────────────────────────────────────────────────────── + +@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, annotées avec leurs demandes d'impression.""" + if not _require_auth(request): + return JSONResponse({"error": "Non authentifié"}, status_code=401) + + pb = request.app.state.photobooth_service + printer_svc = request.app.state.printer_service + + # Récupère toutes les photos + 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] + + # Construit les URLs + for p in page_photos: + pid = _get_id(p) + p["photo_id"] = pid + p["full_url"] = pb.media_url(pid) + p["thumb_url"] = pb.thumbnail_url(pid) + + # Croise avec la file d'impression en attente (une seule requête SQLite) + try: + pending_map = await printer_svc.get_pending_by_photo_id() + for p in page_photos: + pid = p.get("photo_id", "") + requests = pending_map.get(pid, []) + p["print_requests"] = requests + p["print_pending"] = len([r for r in requests if r["status"] == "pending"]) + p["print_printing"] = len([r for r in requests if r["status"] == "printing"]) + except Exception as e: + logger.warning("Impossible de croiser avec print_queue: %s", e) + for p in page_photos: + p["print_requests"] = [] + p["print_pending"] = 0 + p["print_printing"] = 0 + + return { + "photos": page_photos, + "total": total, + "page": page, + "pages": max(1, (total + limit - 1) // limit), + } + + +# ── Demandes d'impression ───────────────────────────────────────────────────── + +@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), + immediate: bool = Query(default=False, description="True = imprimer maintenant sans passer par la file"), +): + """Ajoute une demande d'impression (ou imprime immédiatement si immediate=true). + + En mode 'validation', la demande est mise en file — l'admin valide via /admin/print. + En mode 'direct' ou si immediate=true, l'impression est lancée tout de suite. + """ + if not _require_auth(request): + 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 + cfg = request.app.state.config + + filename = _find_file(cfg.photobooth.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) + + should_print_now = (cfg.print.mode == "direct") or immediate + + if should_print_now: + led.play("printing") + result = await printer_svc.execute_print(entry["id"], copies) + await ws.broadcast({"type": "print_result", "result": result, "photo_id": photo_id}) + led.play("finished" if result["success"] else "error") + return {**result, "entry_id": entry["id"], "photo_id": photo_id} + + # Mode validation : juste en file + await ws.broadcast({ + "type": "print_request", + "entry": entry, + "photo_id": photo_id, + }) + return { + "ok": True, + "queued": True, + "entry_id": entry["id"], + "photo_id": photo_id, + "mode": cfg.print.mode, + } + + +@router.delete("/admin/api/gallery/print/{photo_id}") +async def admin_cancel_print_requests(request: Request, photo_id: str): + """Annule toutes les demandes d'impression en attente pour une photo.""" + if not _require_auth(request): + return JSONResponse({"error": "Non authentifié"}, status_code=401) + + printer_svc = request.app.state.printer_service + ws = request.app.state.ws_manager + + count = await printer_svc.cancel_by_photo_id(photo_id) + if count: + await ws.broadcast({"type": "print_cancelled_for_photo", "photo_id": photo_id, "count": count}) + return {"ok": True, "photo_id": photo_id, "cancelled": count} + + +@router.get("/admin/api/gallery/print-status") +async def admin_print_status(request: Request): + """Retourne le dict {photo_id_stem: pending_count} pour toute la file. + + Utilisé par la galerie pour mettre à jour les badges sans recharger les photos. + """ + if not _require_auth(request): + return JSONResponse({"error": "Non authentifié"}, status_code=401) + + printer_svc = request.app.state.printer_service + pending_map = await printer_svc.get_pending_by_photo_id() + + # Format compact : {stem: count} pour minimiser la taille de la réponse + return { + stem: len(entries) + for stem, entries in pending_map.items() + } + + +# ── Suppression ─────────────────────────────────────────────────────────────── + +@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 _require_auth(request): + 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} diff --git a/backend/api/event_api.py b/backend/api/event_api.py new file mode 100644 index 0000000..4810981 --- /dev/null +++ b/backend/api/event_api.py @@ -0,0 +1,99 @@ +"""API de gestion des événements et statistiques.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from backend.services.event_service import slugify + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class EventUpdate(BaseModel): + name: str + new_event: bool = False # True = archive l'ancien et crée un nouveau + + +@router.get("/event") +async def get_current_event(request: Request): + """Retourne l'événement en cours avec ses statistiques.""" + cfg = request.app.state.config + event_svc = request.app.state.event_service + + ev = cfg.event + stats = await event_svc.get_stats(ev.slug) + + return { + "name": ev.name, + "slug": ev.slug, + "started_at": ev.started_at, + "started_at_iso": ( + datetime.fromtimestamp(ev.started_at).isoformat() + if ev.started_at + else None + ), + "stats": stats, + } + + +@router.put("/event") +async def update_event(request: Request, payload: EventUpdate): + """Met à jour l'événement en cours. + + Si new_event=True : archive le précédent et démarre un nouvel événement + (les stats repartent de zéro). + Si new_event=False : renomme l'événement en cours (même slug ou nouveau slug, + les stats existantes restent sous l'ancien slug). + """ + cfg = request.app.state.config + config_svc = request.app.state.config_service + event_svc = request.app.state.event_service + + new_slug = slugify(payload.name) + now = datetime.now().timestamp() + + old_slug = cfg.event.slug + + if payload.new_event and old_slug and old_slug != new_slug: + # Archive l'ancien événement + await event_svc.archive_event(old_slug) + + # Crée ou met à jour la ligne dans la DB + await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now) + + # Persiste dans settings.yaml + started = now if payload.new_event or not cfg.event.started_at else cfg.event.started_at + config_svc.save_event_config(payload.name, new_slug, started) + + stats = await event_svc.get_stats(new_slug) + return { + "ok": True, + "name": payload.name, + "slug": new_slug, + "started_at": started, + "new_event": payload.new_event, + "stats": stats, + } + + +@router.get("/event/history") +async def get_event_history(request: Request): + """Retourne tous les événements passés avec leurs statistiques.""" + event_svc = request.app.state.event_service + history = await event_svc.get_history() + + # Ajoute les dates ISO pour l'affichage + for ev in history: + if ev.get("started_at"): + ev["started_at_iso"] = datetime.fromtimestamp(ev["started_at"]).strftime("%d/%m/%Y %H:%M") + if ev.get("ended_at"): + ev["ended_at_iso"] = datetime.fromtimestamp(ev["ended_at"]).strftime("%d/%m/%Y %H:%M") + else: + ev["ended_at_iso"] = None + + return {"events": history} diff --git a/backend/api/gallery.py b/backend/api/gallery.py index 2713c8f..107abeb 100644 --- a/backend/api/gallery.py +++ b/backend/api/gallery.py @@ -1,12 +1,15 @@ -"""Galerie publique — accessible sans authentification.""" +"""Galerie publique -- accessible sans authentification.""" +import asyncio import logging - -from fastapi import APIRouter, Request, Query -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from fastapi.templating import Jinja2Templates +from datetime import datetime from pathlib import Path +import httpx +from fastapi import APIRouter, Request, Query +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates + logger = logging.getLogger(__name__) router = APIRouter() @@ -15,7 +18,6 @@ _templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "fr @router.get("/", response_class=HTMLResponse) async def index(request: Request): - """Page d'accueil → redirige vers la galerie publique.""" return RedirectResponse(url="/gallery") @@ -23,7 +25,7 @@ async def index(request: Request): async def gallery_page(request: Request): cfg = request.app.state.config if not cfg.gallery.public_enabled: - return HTMLResponse("

Galerie désactivée

", status_code=403) + return HTMLResponse("

Galerie desactivee

", status_code=403) return _templates.TemplateResponse("public/gallery.html", {"request": request, "config": cfg}) @@ -33,13 +35,9 @@ async def api_gallery_photos( 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) @@ -47,7 +45,6 @@ async def api_gallery_photos( 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) @@ -64,9 +61,37 @@ async def api_gallery_photos( @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.""" + """Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours.""" pb = request.app.state.photobooth_service - return RedirectResponse(url=pb.media_url(photo_id)) + cfg = request.app.state.config + event_svc = getattr(request.app.state, "event_service", None) + + img_url = pb.media_url(photo_id) + slug = cfg.event.slug or "photomaton" + date_str = datetime.now().strftime("%Y-%m-%d") + filename = f"{slug}_By_LSDW_{date_str}.jpg" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(img_url) + r.raise_for_status() + + if event_svc: + asyncio.create_task(event_svc.increment(slug, "downloads")) + + content_type = r.headers.get("content-type", "image/jpeg") + return Response( + content=r.content, + media_type=content_type, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(len(r.content)), + "Cache-Control": "no-cache", + }, + ) + except Exception as e: + logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e) + return RedirectResponse(url=img_url) def _is_image(item: dict) -> bool: diff --git a/backend/api/leds_api.py b/backend/api/leds_api.py index be9d361..7223b41 100644 --- a/backend/api/leds_api.py +++ b/backend/api/leds_api.py @@ -49,3 +49,52 @@ async def leds_off(request: Request): led = request.app.state.led_service led.play("off") return {"ok": True} + + +@router.get("/leds/effects") +async def get_effects(request: Request): + """Retourne la configuration de tous les effets LED.""" + cfg = request.app.state.config.leds + return { + name: { + "color": effect.get("color", [0, 0, 0]), + "flash_duration": effect.get("flash_duration", 0.1), + "flashes": effect.get("flashes", 2), + "speed": effect.get("speed", 0.05), + "duration": effect.get("duration", 2.0), + } + for name, effect in cfg.effects.items() + } + + +@router.put("/leds/effect/{effect_name}") +async def update_effect( + request: Request, + effect_name: str, + r: int = Query(default=None, ge=0, le=255), + g: int = Query(default=None, ge=0, le=255), + b: int = Query(default=None, ge=0, le=255), + flash_duration: float = Query(default=None, ge=0.05, le=2.0), + flashes: int = Query(default=None, ge=1, le=5), + save: bool = Query(default=True), +): + """Met a jour la configuration d'un effet LED (couleur, timings). + + Exemple : PUT /api/leds/effect/capture?r=255&g=200&b=80&flash_duration=0.3&flashes=2 + """ + cfg = request.app.state.config.leds + config_svc = request.app.state.config_service + + effect_raw = cfg.effects.setdefault(effect_name, {}) + + if r is not None and g is not None and b is not None: + effect_raw["color"] = [r, g, b] + if flash_duration is not None: + effect_raw["flash_duration"] = flash_duration + if flashes is not None: + effect_raw["flashes"] = flashes + + if save: + config_svc.save_led_effect(effect_name, effect_raw) + + return {"ok": True, "effect": effect_name, "config": effect_raw} diff --git a/backend/api/print_api.py b/backend/api/print_api.py index e0ae088..fe79da4 100644 --- a/backend/api/print_api.py +++ b/backend/api/print_api.py @@ -1,5 +1,6 @@ """API gestion de la file d'attente d'impression.""" +import asyncio import logging from pathlib import Path @@ -33,6 +34,12 @@ async def print_request( await ws.broadcast({"type": "print_request", "entry": entry}) + # Compteur statistiques événement + event_svc = getattr(request.app.state, "event_service", None) + if event_svc: + slug = request.app.state.config.event.slug + asyncio.create_task(event_svc.increment(slug, "print_requests")) + # Feedback LED si impression directe if request.app.state.config.print.mode == "direct": led.play("printing") @@ -71,6 +78,10 @@ async def execute_print( if result["success"]: led.play("finished") + event_svc = getattr(request.app.state, "event_service", None) + if event_svc: + slug = request.app.state.config.event.slug + asyncio.create_task(event_svc.increment(slug, "prints_done")) else: led.play("error") @@ -112,3 +123,40 @@ async def set_print_mode(request: Request, mode: str = Query(...)): config_svc.save_print_mode(mode) request.app.state.config.print.mode = mode return {"ok": True, "mode": mode} + + +# ── Gestion avancée des imprimantes CUPS ────────────────────────────────────── + +@router.post("/print/printers/{printer_name}/enable") +async def printer_enable(request: Request, printer_name: str): + """Active une imprimante CUPS (cupsenable + cupsaccept).""" + ok = await request.app.state.printer_service.enable_printer(printer_name) + return {"ok": ok, "printer": printer_name, "action": "enable"} + + +@router.post("/print/printers/{printer_name}/disable") +async def printer_disable(request: Request, printer_name: str): + """Désactive une imprimante CUPS (cupsdisable).""" + ok = await request.app.state.printer_service.disable_printer(printer_name) + return {"ok": ok, "printer": printer_name, "action": "disable"} + + +@router.post("/print/printers/{printer_name}/reject") +async def printer_reject(request: Request, printer_name: str): + """Refuse les nouveaux jobs sans stopper l'impression en cours.""" + ok = await request.app.state.printer_service.reject_jobs(printer_name) + return {"ok": ok, "printer": printer_name, "action": "reject"} + + +@router.get("/print/printers/{printer_name}/jobs") +async def printer_jobs(request: Request, printer_name: str): + """Liste les jobs CUPS en cours pour une imprimante.""" + jobs = await request.app.state.printer_service.get_cups_jobs(printer_name) + return {"printer": printer_name, "jobs": jobs} + + +@router.delete("/print/printers/jobs/{job_id}") +async def cancel_one_job(request: Request, job_id: str): + """Annule un job CUPS précis par son ID.""" + ok = await request.app.state.printer_service.cancel_cups_job(job_id) + return {"ok": ok, "job_id": job_id} diff --git a/backend/api/system_api.py b/backend/api/system_api.py index f98df58..5e2f36c 100644 --- a/backend/api/system_api.py +++ b/backend/api/system_api.py @@ -1,8 +1,10 @@ """API de surveillance des ressources système.""" import logging -from fastapi import APIRouter, Request +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() @@ -50,3 +52,111 @@ async def control_relay(request: Request, state: str = "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 en temps réel — effectif à la prochaine capture, + sans redémarrage. + """ + 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) + + return {"ok": ok, "visible": visible} + + +@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-1 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, + } + } diff --git a/backend/api/webhooks.py b/backend/api/webhooks.py index 5d6dda4..a849d31 100644 --- a/backend/api/webhooks.py +++ b/backend/api/webhooks.py @@ -1,16 +1,6 @@ -"""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 -""" +"""Webhooks recus de photobooth-app via plugin_commander.""" +import asyncio import logging from fastapi import APIRouter, Request, Query @@ -18,7 +8,6 @@ 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 @@ -33,30 +22,25 @@ async def photobooth_webhook( 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) + led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN) 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)) + event_svc = getattr(request.app.state, "event_service", None) + if event_svc: + slug = request.app.state.config.event.slug + asyncio.create_task(event_svc.increment(slug, "photos_taken")) case "start": led.play("idle") @@ -65,28 +49,12 @@ async def photobooth_webhook( led.play("off") case _: - logger.debug("Événement inconnu: %s", event_key) + logger.debug("Evenement 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 diff --git a/backend/services/button_service.py b/backend/services/button_service.py index 729c216..3333b11 100644 --- a/backend/services/button_service.py +++ b/backend/services/button_service.py @@ -42,6 +42,7 @@ class ButtonService: self._click_count = 0 self._press_time: float = 0.0 self._click_timer: threading.Timer | None = None + self._long_timer: threading.Timer | None = None # ← fix: init explicite self._long_press_fired = False self._relay_enabled = True self._lock = threading.Lock() @@ -110,6 +111,33 @@ class ButtonService: def simulate_long_press(self): asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop) + def update_timings( + self, + double_click_ms: int | None = None, + long_press_ms: int | None = None, + debounce_ms: int | None = None, + max_clicks: int | None = None, + ): + """Met à jour les timings à chaud (sans redémarrage du service).""" + if double_click_ms is not None: + self._cfg.double_click_ms = double_click_ms + if long_press_ms is not None: + self._cfg.long_press_ms = long_press_ms + if max_clicks is not None: + self._cfg.max_clicks = max(1, min(4, max_clicks)) + if debounce_ms is not None: + self._cfg.debounce_ms = debounce_ms + if self._button: + try: + self._button.bounce_time = debounce_ms / 1000 + except Exception: + pass + logger.info( + "Timings bouton mis à jour : double_click=%dms, long_press=%dms, debounce=%dms, max_clicks=%d", + self._cfg.double_click_ms, self._cfg.long_press_ms, + self._cfg.debounce_ms, self._cfg.max_clicks, + ) + # ── Callbacks GPIO ──────────────────────────────────────────────────────── def _on_pressed(self): diff --git a/backend/services/config_service.py b/backend/services/config_service.py index c3941f2..b250602 100644 --- a/backend/services/config_service.py +++ b/backend/services/config_service.py @@ -28,6 +28,7 @@ class PhotoboothConfig: 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" + show_delete_button: bool = False @dataclass @@ -89,6 +90,13 @@ class GalleryConfig: qr_base_url: str = "https://photomaton.lessapinsduweb.com" +@dataclass +class EventConfig: + name: str = "Evenement" + slug: str = "evenement" + started_at: float = 0.0 + + @dataclass class Config: app: AppConfig = field(default_factory=AppConfig) @@ -97,6 +105,7 @@ class Config: leds: LEDConfig = field(default_factory=LEDConfig) print: PrintConfig = field(default_factory=PrintConfig) gallery: GalleryConfig = field(default_factory=GalleryConfig) + event: EventConfig = field(default_factory=EventConfig) button_actions: dict = field(default_factory=dict) @@ -107,7 +116,7 @@ class ConfigService: def load(self) -> Config: if not self.path.exists(): - logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut") + logger.warning("Config introuvable: %s", self.path) self._config = Config() return self._config @@ -143,30 +152,60 @@ class ConfigService: if "gallery" in raw: cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)}) + if "event" in raw: + cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)}) + cfg.button_actions = raw.get("button_actions", { 1: {"label": "Photo normale", "photobooth_index": 0}, - 2: {"label": "Photo étoile", "photobooth_index": 1}, + 2: {"label": "Photo etoile", "photobooth_index": 1}, 3: {"label": "Photo cailloux", "photobooth_index": 2}, - 4: {"label": "Photo soirée", "photobooth_index": 3}, + 4: {"label": "Photo soiree", "photobooth_index": 3}, }) self._config = cfg - logger.info("Configuration chargée depuis %s", self.path) + logger.info("Configuration chargee 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_show_delete_button(self, visible: bool): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw.setdefault("photobooth", {})["show_delete_button"] = visible + 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.photobooth.show_delete_button = visible + + def save_button_config(self, data: dict): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw.setdefault("button", {}).update(data) + with open(self.path, "w", encoding="utf-8") as f: + yaml.dump(raw, f, allow_unicode=True, default_flow_style=False) + if self._config: + for k, v in data.items(): + if hasattr(self._config.button, k): + setattr(self._config.button, k, v) + + def save_event_config(self, name: str, slug: str, started_at: float): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw["event"] = {"name": name, "slug": slug, "started_at": started_at} + 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.event.name = name + self._config.event.slug = slug + self._config.event.started_at = started_at + def save_print_mode(self, mode: str): with open(self.path, encoding="utf-8") as f: raw = yaml.safe_load(f) or {} @@ -181,3 +220,12 @@ class ConfigService: if self._config is None: self.load() return self._config + + def save_led_effect(self, effect_name: str, effect_data: dict): + with open(self.path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + raw.setdefault("leds", {}).setdefault("effects", {})[effect_name] = effect_data + 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.leds.effects[effect_name] = effect_data diff --git a/backend/services/event_service.py b/backend/services/event_service.py new file mode 100644 index 0000000..30bc5b9 --- /dev/null +++ b/backend/services/event_service.py @@ -0,0 +1,125 @@ +"""Service de gestion des événements et statistiques. + +Chaque événement (soirée, mariage, fête...) a ses propres compteurs : + - photos_taken : incrémenté par le webhook 'finished' de photobooth-app + - print_requests : incrémenté à chaque demande d'impression dans la file + - prints_done : incrémenté à chaque impression réussie + - downloads : incrémenté à chaque téléchargement via /api/gallery/download/ +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from datetime import datetime +from pathlib import Path + +import aiosqlite + +logger = logging.getLogger(__name__) + + +def slugify(name: str) -> str: + """Transforme un nom d'événement en slug ASCII sans accents ni espaces.""" + # Normalise les caractères Unicode (enlève les accents) + nfkd = unicodedata.normalize("NFD", name) + ascii_str = "".join(c for c in nfkd if unicodedata.category(c) != "Mn") + ascii_str = ascii_str.lower() + # Garde lettres, chiffres, espaces, tirets + ascii_str = re.sub(r"[^\w\s-]", "", ascii_str) + # Remplace espaces/tirets multiples par un underscore + ascii_str = re.sub(r"[\s_-]+", "_", ascii_str) + return ascii_str.strip("_") or "evenement" + + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + started_at REAL NOT NULL, + ended_at REAL, + photos_taken INTEGER NOT NULL DEFAULT 0, + print_requests INTEGER NOT NULL DEFAULT 0, + prints_done INTEGER NOT NULL DEFAULT 0, + downloads INTEGER NOT NULL DEFAULT 0 +); +""" + +_VALID_COUNTERS = {"photos_taken", "print_requests", "prints_done", "downloads"} + + +class EventService: + def __init__(self): + self._db: aiosqlite.Connection | None = None + + async def init_db(self, db_path: Path): + db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(str(db_path)) + self._db.row_factory = aiosqlite.Row + await self._db.execute(_SCHEMA) + await self._db.commit() + logger.info("EventService initialisé : %s", db_path) + + async def ensure_event(self, slug: str, name: str, started_at: float): + """Crée la ligne pour cet événement si elle n'existe pas encore.""" + await self._db.execute( + """INSERT INTO events (name, slug, started_at) + VALUES (?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET name = excluded.name""", + (name, slug, started_at), + ) + await self._db.commit() + + async def archive_event(self, slug: str): + """Marque un événement comme terminé (ended_at = maintenant).""" + await self._db.execute( + "UPDATE events SET ended_at = ? WHERE slug = ? AND ended_at IS NULL", + (datetime.now().timestamp(), slug), + ) + await self._db.commit() + + async def increment(self, slug: str, counter: str): + """Incrémente un compteur de l'événement identifié par son slug. + + Crée automatiquement une ligne si elle n'existe pas. + """ + if counter not in _VALID_COUNTERS: + logger.warning("Compteur inconnu : %s", counter) + return + # Upsert : insère si absent, sinon incrémente + await self._db.execute( + f"""INSERT INTO events (name, slug, started_at, {counter}) + VALUES (?, ?, unixepoch(), 1) + ON CONFLICT(slug) DO UPDATE SET {counter} = {counter} + 1""", + (slug, slug), + ) + await self._db.commit() + + async def get_stats(self, slug: str) -> dict: + """Retourne les stats pour un slug donné.""" + async with self._db.execute( + """SELECT photos_taken, print_requests, prints_done, downloads + FROM events WHERE slug = ?""", + (slug,), + ) as cur: + row = await cur.fetchone() + if not row: + return {"photos_taken": 0, "print_requests": 0, "prints_done": 0, "downloads": 0} + return dict(row) + + async def get_history(self) -> list[dict]: + """Retourne tous les événements triés du plus récent au plus ancien.""" + async with self._db.execute( + """SELECT id, name, slug, started_at, ended_at, + photos_taken, print_requests, prints_done, downloads + FROM events + ORDER BY started_at DESC""" + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def close(self): + if self._db: + await self._db.close() diff --git a/backend/services/led_service.py b/backend/services/led_service.py index c43af06..1ed890b 100644 --- a/backend/services/led_service.py +++ b/backend/services/led_service.py @@ -1,13 +1,14 @@ -"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs). +"""Service de controle 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) +Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem) +Mode mock : log uniquement (developpement) """ from __future__ import annotations import asyncio import logging +import math import threading import time from typing import Callable @@ -16,13 +17,12 @@ 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 + from rpi_ws281x import PixelStrip, Color as WS_Color HAS_WS281X = True except ImportError: HAS_WS281X = False - logger.warning("rpi_ws281x non disponible — mode mock LED activé") + logger.warning("rpi_ws281x non disponible -- mode mock LED") class WS_Color: # type: ignore def __init__(self, r: int, g: int, b: int): @@ -31,7 +31,7 @@ except ImportError: return f"Color({self.r},{self.g},{self.b})" class PixelStrip: # type: ignore - def __init__(self, *args, **kwargs): pass + def __init__(self, *a, **kw): pass def begin(self): pass def show(self): pass def setPixelColor(self, i, c): pass @@ -40,16 +40,10 @@ except ImportError: 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) + return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2])) 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") @@ -74,16 +68,16 @@ class LEDService: self._cfg.pin, self._cfg.freq_hz, self._cfg.dma, - False, # invert + False, self._cfg.brightness, - 0, # channel + 0, ) try: self._strip.begin() - logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin) + logger.info("Strip WS2812b initialise (%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 + self._strip = PixelStrip() else: self._strip = PixelStrip() @@ -104,10 +98,9 @@ class LEDService: def set_on_change(self, cb: Callable): self._on_change_cb = cb - # ── Commandes publiques (thread-safe) ──────────────────────────────────── + # ── Commandes publiques ─────────────────────────────────────────────────── 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 @@ -116,22 +109,21 @@ class LEDService: 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, + "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(): @@ -143,19 +135,16 @@ class LEDService: fn = effect_func.get(current, self._effect_idle) try: - if current in ("countdown",): + if current == "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) @@ -163,27 +152,23 @@ class LEDService: # ── 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 + t = (1 + math.sin(step * 0.1)) / 2 brightness = max(0.05, t) - color = WS_Color( + self._fill(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 @@ -191,55 +176,68 @@ class LEDService: 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.""" + """Flash photo. + + Utilise la couleur configuree dans settings.yaml > leds > effects > capture. + Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage, + puis la restaure a la valeur normale. + + Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges. + Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255]. + """ cfg = self._cfg.get_effect("capture") - white = WS_Color(255, 255, 255) + flash_color = 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(white) - time.sleep(cfg.flash_duration) + + # Boost luminosite max pendant le flash + if HAS_WS281X and self._strip: + self._strip.setBrightness(255) + + try: + for _ in range(cfg.flashes): + if self._cmd_event.is_set(): + break + self._fill(flash_color) + time.sleep(cfg.flash_duration) + self._fill(off) + # Pause inter-flash plus courte que le flash lui-meme + if _ < cfg.flashes - 1: + time.sleep(cfg.flash_duration * 0.4) + finally: + # Toujours restaurer la luminosite normale meme en cas d'erreur + if HAS_WS281X and self._strip: + self._strip.setBrightness(self._cfg.brightness) 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 @@ -248,22 +246,18 @@ class LEDService: 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), - ), - ) + 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) + time.sleep(cfg.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) diff --git a/backend/services/photobooth_service.py b/backend/services/photobooth_service.py index 25bdb9e..2e3ac0e 100644 --- a/backend/services/photobooth_service.py +++ b/backend/services/photobooth_service.py @@ -125,5 +125,42 @@ class PhotoboothService: results.append(str(f)) return sorted(results) + # ── UI / private.css ────────────────────────────────────────────────────── + + _CSS_HIDE_DELETE = """\ +/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */ +/* Cacher le bouton Supprimer sur l'écran de review après capture */ +.action-button-delete { + display: none !important; +} +""" + + _CSS_SHOW_DELETE = """\ +/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */ +/* Bouton Supprimer visible (activé depuis le dashboard JH Photomaton) */ +/* .action-button-delete { display: none !important; } */ +""" + + async def set_delete_button_visible(self, visible: bool) -> bool: + """Écrit private.css dans userdata pour afficher ou cacher le bouton Supprimer. + + photobooth-app charge automatiquement userdata/private.css à chaque requête + — aucun redémarrage nécessaire, effectif dès la prochaine capture. + """ + css_path = Path(self._cfg.userdata_dir) / "private.css" + try: + css_content = self._CSS_SHOW_DELETE if visible else self._CSS_HIDE_DELETE + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text(css_content, encoding="utf-8") + logger.info("private.css mis à jour : bouton delete %s", "visible" if visible else "caché") + return True + except Exception as e: + logger.error("Impossible d'écrire private.css : %s", e) + return False + + async def get_delete_button_visible(self) -> bool: + """Lit l'état actuel depuis le fichier private.css (ou depuis la config).""" + return self._cfg.show_delete_button + async def close(self): await self._client.aclose() diff --git a/backend/services/printer_service.py b/backend/services/printer_service.py index 75eb15e..f5f280f 100644 --- a/backend/services/printer_service.py +++ b/backend/services/printer_service.py @@ -100,6 +100,35 @@ class PrinterService: async def get_pending(self) -> list[dict]: return await self.get_queue("pending") + async def get_pending_by_photo_id(self) -> dict[str, list]: + """Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives. + + Permet à la galerie admin de savoir quelles photos ont une demande en attente + sans modifier le schéma SQLite — on match par stem du filename. + """ + rows = await self.get_queue("pending") + # Aussi inclure celles "printing" (en cours d'impression) + rows += await self.get_queue("printing") + + result: dict[str, list] = {} + for r in rows: + from pathlib import Path + stem = Path(r["filename"]).stem + result.setdefault(stem, []).append(r) + return result + + async def cancel_by_photo_id(self, photo_stem: str) -> int: + """Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé.""" + pending = await self.get_queue("pending") + from pathlib import Path + cancelled = 0 + for r in pending: + if Path(r["filename"]).stem == photo_stem: + ok = await self.cancel(r["id"]) + if ok: + cancelled += 1 + return cancelled + async def cancel(self, entry_id: str) -> bool: async with self._lock: cursor = await self._db.execute( @@ -186,48 +215,105 @@ class PrinterService: except Exception as e: return {"success": False, "error": str(e)} + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _printer_name(self, p) -> str: + """Accepte une entrée printers qui soit un str ou un dict.""" + return p["name"] if isinstance(p, dict) else str(p) + + def _printer_label(self, p) -> str: + return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p) + # ── Statut imprimantes CUPS ─────────────────────────────────────────────── async def get_printers_status(self) -> list[dict]: - """Retourne le statut des imprimantes CUPS configurées.""" + """Retourne le statut détaillé des imprimantes CUPS configurées.""" statuses = [] for p in self._cfg.printers: - status = await asyncio.to_thread(self._get_printer_status, p["name"]) + name = self._printer_name(p) + status = await asyncio.to_thread(self._get_printer_status, name) statuses.append({ - "name": p["name"], - "label": p["label"], + "name": name, + "label": self._printer_label(p), **status, }) return statuses def _get_printer_status(self, printer_name: str) -> dict: + """Statut CUPS complet pour une imprimante (état, jobs, accepting).""" try: - result = subprocess.run( + # État de l'imprimante + r_state = subprocess.run( ["lpstat", "-p", printer_name], capture_output=True, text=True, timeout=5 ) - output = result.stdout.lower() - if "idle" in output: + out = r_state.stdout.lower() + + if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower(): + return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0} + + if "idle" in out: state = "idle" - elif "printing" in output or "processing" in output: + elif "printing" in out or "processing" in out: state = "printing" - elif "disabled" in output: + elif "stopped" in out or "disabled" in out: 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], + # Est-ce que l'imprimante accepte les nouveaux jobs ? + r_accept = subprocess.run( + ["lpstat", "-a", printer_name], capture_output=True, text=True, timeout=5 ) - jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l]) + accepting = "accepting" in r_accept.stdout.lower() - return {"state": state, "jobs": jobs} + # Liste des jobs CUPS en cours + jobs = self._get_cups_jobs(printer_name) + + return { + "state": state, + "accepting": accepting, + "jobs": jobs, + "jobs_count": len(jobs), + } except Exception as e: - return {"state": "error", "jobs": 0, "error": str(e)} + return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)} + + def _get_cups_jobs(self, printer_name: str) -> list[dict]: + """Retourne la liste des jobs CUPS en cours pour une imprimante.""" + try: + r = subprocess.run( + ["lpstat", "-o", printer_name, "-l"], + capture_output=True, text=True, timeout=5 + ) + jobs = [] + current = {} + for line in r.stdout.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith(printer_name): + if current: + jobs.append(current) + parts = line.split() + current = { + "id": parts[0] if parts else "", + "user": parts[1] if len(parts) > 1 else "", + "size": parts[3] if len(parts) > 3 else "", + "date": " ".join(parts[4:7]) if len(parts) > 6 else "", + "details": [], + } + elif current and ":" in line: + current.setdefault("details", []).append(line) + if current: + jobs.append(current) + return jobs + except Exception: + return [] + + async def get_cups_jobs(self, printer_name: str) -> list[dict]: + return await asyncio.to_thread(self._get_cups_jobs, printer_name) async def cancel_cups_jobs(self, printer_name: str) -> bool: """Annule tous les jobs CUPS pour une imprimante.""" @@ -240,3 +326,43 @@ class PrinterService: except Exception as e: logger.error("Erreur cancel CUPS: %s", e) return False + + async def cancel_cups_job(self, job_id: str) -> bool: + """Annule un job CUPS précis.""" + try: + result = subprocess.run( + ["cancel", job_id], + capture_output=True, text=True, timeout=10 + ) + return result.returncode == 0 + except Exception as e: + logger.error("Erreur cancel CUPS job %s: %s", job_id, e) + return False + + async def enable_printer(self, printer_name: str) -> bool: + """Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs.""" + try: + r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10) + r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10) + return r1.returncode == 0 and r2.returncode == 0 + except Exception as e: + logger.error("Erreur enable printer %s: %s", printer_name, e) + return False + + async def disable_printer(self, printer_name: str) -> bool: + """Désactive l'imprimante CUPS (cupsdisable).""" + try: + result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10) + return result.returncode == 0 + except Exception as e: + logger.error("Erreur disable printer %s: %s", printer_name, e) + return False + + async def reject_jobs(self, printer_name: str) -> bool: + """Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours.""" + try: + result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10) + return result.returncode == 0 + except Exception as e: + logger.error("Erreur reject printer %s: %s", printer_name, e) + return False diff --git a/config/settings.yaml b/config/settings.yaml index dc70c17..e60b308 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -1,5 +1,5 @@ # ============================================================ -# JH Photomaton — Configuration principale +# JH Photomaton -- Configuration principale # Association Les Sapins Du Web (LSDW) # ============================================================ @@ -11,30 +11,37 @@ app: secret_key: "photomaton-jh-2026-secret-change-me" admin_password: "PhotoBooth2026!" -# --- Intégration photobooth-app --- +# --- Integration photobooth-app --- photobooth: - base_url: "http://localhost:8083" + base_url: "http://localhost:8000" data_dir: "/home/pi/photobooth-data" config_file: "/home/pi/.config/photobooth-app/config.json" media_dir: "/home/pi/photobooth-data/media/processed_full" userdata_dir: "/home/pi/photobooth-data/userdata" + show_delete_button: false + +# --- Evenement en cours --- +event: + name: "Evenement" + slug: "evenement" + started_at: 0 # --- Bouton physique --- button: - pin: 23 # GPIO BCM (input, pull-up interne) - relay_pin: 12 # GPIO BCM (output, relay ON/OFF bouton 12V) + pin: 23 + relay_pin: 12 debounce_ms: 50 - double_click_ms: 400 # Délai max entre clics pour multi-clic - long_press_ms: 1500 # Durée min long appui - max_clicks: 4 # Nombre max de clics reconnus - long_press_action: "print_last" # print_last | none + double_click_ms: 400 + long_press_ms: 1500 + max_clicks: 4 + long_press_action: "print_last" print_enabled: true # --- Anneau LED WS2812b --- leds: - pin: 18 # GPIO BCM (PWM hardware, pin physique 12) - count: 35 # Nombre de LEDs dans l'anneau - brightness: 180 # 0-255 + pin: 18 + count: 35 + brightness: 180 # Luminosite normale (0-255) freq_hz: 800000 dma: 10 strip_type: "WS2812" @@ -43,36 +50,51 @@ leds: color: [0, 30, 80] mode: "breathe" speed: 0.025 + countdown: color: [0, 200, 80] mode: "fill_progressive" + capture: - color: [255, 255, 255] + # BLANC CHAUD pour photo -- les WS2812b ont une LED bleue tres efficace. + # [255,255,255] donne un rendu tres bleu sur les photos. + # Reduisez le bleu (3eme valeur) et augmentez le rouge pour corriger. + # Exemples : + # [255, 200, 80] -> blanc chaud (recommande pour debuter) + # [255, 180, 40] -> blanc tres chaud / jaune + # [255, 220, 120] -> blanc neutre legerement chaud + color: [255, 200, 80] mode: "flash" flashes: 2 - flash_duration: 0.1 + # Duree en secondes de chaque flash (plus long = plus de lumiere) + flash_duration: 0.30 + captured: color: [150, 0, 200] mode: "solid" + finished: color: [0, 100, 255] mode: "solid" duration: 2.0 + printing: color: [0, 120, 255] mode: "spin" speed: 0.05 + error: color: [220, 0, 0] mode: "flash" flashes: 4 + disabled: color: [60, 0, 0] mode: "solid" # --- Impression --- print: - mode: "validation" # direct | gallery | validation + mode: "validation" script_path: "/home/pi/photobooth-data/script/script_print.sh" default_copies: 1 printers: @@ -88,9 +110,8 @@ gallery: qr_base_url: "https://photomaton.lessapinsduweb.com" # --- Mapping clics bouton -> actions photobooth-app --- -# n clics -> photobooth_index dans la liste actions.image de config.json button_actions: 1: { label: "Photo normale", photobooth_index: 0 } - 2: { label: "Photo étoile", photobooth_index: 1 } + 2: { label: "Photo etoile", photobooth_index: 1 } 3: { label: "Photo cailloux", photobooth_index: 2 } - 4: { label: "Photo soirée", photobooth_index: 3 } + 4: { label: "Photo soiree", photobooth_index: 3 } diff --git a/docs/BACKUP-RESTORE.md b/docs/BACKUP-RESTORE.md new file mode 100644 index 0000000..b48d97f --- /dev/null +++ b/docs/BACKUP-RESTORE.md @@ -0,0 +1,176 @@ +# Sauvegarde & Restauration — JH Photomaton + +## Sauvegarder + +```bash +sudo bash /home/pi/jh-photomaton/scripts/backup-configs.sh +# → crée des archives dans ~/photomaton-backups/ +``` + +Pour sauvegarder vers une clé USB : +```bash +sudo bash /home/pi/jh-photomaton/scripts/backup-configs.sh /media/pi/USB +``` + +--- + +## Restauration RaspAP + +```bash +# Réinstaller RaspAP sur un Pi frais +curl -sL https://install.raspap.com | bash + +# Puis restaurer la config : +sudo tar -xzf raspap-YYYYMMDD-HHMMSS.tar.gz -C / +sudo systemctl restart hostapd dnsmasq dhcpcd lighttpd +``` + +**Fichiers critiques restaurés :** +- `/etc/hostapd/hostapd.conf` — SSID, mot de passe WiFi, canal +- `/etc/dnsmasq.conf` + `/etc/dnsmasq.d/` — DHCP & DNS +- `/etc/dhcpcd.conf` — IP statique 10.3.141.1 sur wlan0 + +**Vérification :** +```bash +sudo systemctl status hostapd dnsmasq +# Le réseau "Photomaton" doit apparaître dans les WiFi disponibles +``` + +--- + +## Restauration Zoraxy + +```bash +# Télécharger le binaire (même version qu'avant) +mkdir ~/zoraxy && cd ~/zoraxy +wget https://github.com/tobychui/zoraxy/releases/download/v3.3.2/zoraxy_linux_arm64 -O zoraxy +chmod +x zoraxy + +# Restaurer la config (rules, settings) +tar -xzf zoraxy-YYYYMMDD-HHMMSS.tar.gz -C ~/zoraxy/ + +# Restaurer les certificats Let's Encrypt +sudo tar -xzf letsencrypt-YYYYMMDD-HHMMSS.tar.gz -C / + +# Réinstaller le service systemd +sudo cp ~/jh-photomaton/systemd/zoraxy.service /etc/systemd/system/ +# ou recréer le fichier (voir chapitre 6 du guide d'installation) +sudo systemctl daemon-reload +sudo systemctl enable zoraxy +sudo systemctl start zoraxy +``` + +**Vérification :** +```bash +sudo systemctl status zoraxy +curl -sk https://photomaton.lessapinsduweb.com/ | head -3 +``` + +**Note sur les certificats :** Let's Encrypt expire tous les 90 jours. +Pour renouveler (Pi connecté à internet via Ethernet) : +```bash +sudo certbot renew +# Puis ré-importer dans Zoraxy → TLS/SSL → Import Certificate +``` + +--- + +## Restauration CUPS (Imprimantes) + +### Option A — Restaurer depuis l'archive (Pi de remplacement) + +```bash +# Installer CUPS +sudo apt install -y cups printer-driver-gutenprint +sudo systemctl start cups + +# Restaurer la config +sudo tar -xzf cups-YYYYMMDD-HHMMSS.tar.gz -C / +sudo systemctl restart cups + +# Vérifier +lpstat -p +# Selphy_Blanche_WiFi et Selphy_Noire_WiFi doivent apparaître +``` + +### Option B — Réajouter les imprimantes manuellement (plus fiable après un crash) + +```bash +# Brancher les Canon Selphy en USB, puis : +sudo usermod -aG lpadmin pi +http://localhost:631 # Interface web CUPS → Administration → Add Printer +# Nom: Selphy_Blanche_WiFi (EXACTEMENT ce nom — utilisé par le script d'impression) +# Driver: Canon SELPHY CP1300 - Gutenprint +``` + +**Vérification après restauration :** +```bash +# Test d'impression (adapter le chemin) +/home/pi/photobooth-data/script/script_print.sh \ + "/home/pi/photobooth-data/media/processed_full/test.jpg" \ + "image" "test" "1" +# Doit retourner : PRINTED:Selphy_Blanche_WiFi:1 +``` + +--- + +## Restauration JH Photomaton + +```bash +cd /home/pi +git clone https://gitea.lespatas.ovh/admin/photoBooth.git jh-photomaton +cd jh-photomaton + +# Restaurer la config locale (settings.yaml + base SQLite) +tar -xzf jh-photomaton-YYYYMMDD-HHMMSS.tar.gz -C /home/pi/jh-photomaton/ + +# Restaurer la config photobooth-app +tar -xzf photobooth-app-config-YYYYMMDD-HHMMSS.tar.gz -C / + +# Installer +sudo bash scripts/install.sh + +# Démarrer +sudo systemctl start jh-photomaton +sudo systemctl status jh-photomaton +``` + +--- + +## Sauvegarde automatique (planifiée) + +Pour sauvegarder chaque semaine automatiquement : +```bash +# Ajouter dans crontab root +sudo crontab -e + +# Sauvegarde chaque dimanche à 3h du matin +0 3 * * 0 bash /home/pi/jh-photomaton/scripts/backup-configs.sh /home/pi/photomaton-backups >> /home/pi/photomaton-backups/backup.log 2>&1 + +# Nettoyer les backups de plus de 30 jours +0 4 * * 0 find /home/pi/photomaton-backups -name "*.tar.gz" -mtime +30 -delete +``` + +Ou via JH Photomaton (depuis le panel admin → Réglages, à implémenter) : +```bash +# Déclencher une sauvegarde manuelle depuis le dashboard +curl -X POST http://localhost:8090/api/system/backup +``` + +--- + +## Sauvegarde de la carte SD entière + +La méthode la plus sûre avant un event : + +```bash +# Sur le PC (Linux/macOS) avec la carte SD insérée +# Identifier la carte : lsblk ou diskutil list (macOS) +sudo dd if=/dev/sdX bs=4M status=progress | gzip > photomaton-$(date +%Y%m%d).img.gz + +# Restaurer +zcat photomaton-YYYYMMDD.img.gz | sudo dd of=/dev/sdX bs=4M status=progress +``` + +> Remplacer `/dev/sdX` par le device de la carte SD (ex: `/dev/sdb` ou `/dev/disk2`). +> Une image complète 32 Go compressée fait ~4-8 Go selon l'occupation. diff --git a/docs/INSTALL-FROM-SCRATCH.md b/docs/INSTALL-FROM-SCRATCH.md new file mode 100644 index 0000000..9fcd42a --- /dev/null +++ b/docs/INSTALL-FROM-SCRATCH.md @@ -0,0 +1,668 @@ +# Guide d'installation from scratch — JH Photomaton +**Raspberry Pi 4 (2 Go) · Pi Camera Module V3 · Pi OS Lite 64-bit + cage (kiosk Wayland)** +*Les Sapins Du Web — juillet 2026* + +--- + +## Vue d'ensemble du stack + +``` +Internet / réseau local + │ + [Zoraxy] :443 / :80 — reverse proxy HTTPS + │ + [RaspAP] — point d'accès WiFi "Photomaton" + │ + ┌──────┴──────────────────────┐ + │ photobooth-app :8000 │ — kiosque, caméra, frames + │ JH Photomaton :8090 │ — admin, LEDs, bouton, imprimantes + │ CUPS :631 │ — Canon Selphy CP1300 x2 + └─────────────────────────────┘ +``` + +**Matériel :** +- Raspberry Pi 4 (2 Go RAM), carte SD 32 Go+ +- Pi Camera Module V3 (Sony IMX708, 12 MP) +- Anneau LED WS2812b — 35 LEDs — GPIO 18 +- Bouton arcade — GPIO 23 / Relay — GPIO 12 +- 2× Canon Selphy CP1300 + +--- + +## Étape 1 — Flasher la carte SD + +Utiliser **Raspberry Pi Imager** sur ton PC : + +- OS : **Raspberry Pi OS Lite (64-bit)** — sans bureau *(choisir "Raspberry Pi OS (other)" → "Lite")* +- Activer SSH, définir utilisateur `pi` et mot de passe dans les options avancées +- Hostname : `photomaton` +- WiFi : **ne pas configurer** (RaspAP va le prendre en charge) + +> **Pourquoi Lite ?** +> Le bureau complet (PIXEL) consomme ~450–600 Mo RAM à l'idle. +> Lite + cage + Chromium consomme ~150–250 Mo — soit ~300 Mo libérés pour picamera2 +> et le reste du stack. + +> **Bookworm vs Trixie :** Bookworm est plus stable pour picamera2 et rpi-ws281x. +> Si l'ancienne carte tournait sur Trixie sans problème, reste sur Trixie. + +Flasher, insérer dans le Pi, brancher en Ethernet, démarrer. + +--- + +## Étape 2 — Premier démarrage et préparation système + +```bash +# Trouver l'IP du Pi sur ton réseau local (depuis ton PC) +# Puis se connecter +ssh pi@ + +# Mise à jour complète +sudo apt update && sudo apt full-upgrade -y +sudo apt autoremove -y + +# Outils essentiels +sudo apt install -y git curl wget vim rsync python3-pip python3-venv \ + python3-dev build-essential gcc make + +# Redémarrer pour appliquer les mises à jour du noyau +sudo reboot +``` + +--- + +## Étape 3 — Désactiver l'audio (requis pour les LEDs WS2812b) + +Les LEDs WS2812b utilisent le PWM sur GPIO 18, qui entre en conflit avec la carte son intégrée. + +```bash +sudo nano /boot/firmware/config.txt +``` + +Trouver la ligne `dtparam=audio=on` et la remplacer par : + +``` +dtparam=audio=off +``` + +Ajouter aussi en bas du fichier (si pas déjà présent) : + +``` +# Désactive le Bluetooth pour libérer l'UART si besoin +dtoverlay=disable-bt +``` + +Sauvegarder et redémarrer : + +```bash +sudo reboot +``` + +--- + +## Étape 4 — Installer l'environnement kiosk (cage + Chromium) + +Sur Pi OS Lite, il n'y a pas de bureau. On installe uniquement ce qu'il faut pour afficher Chromium en mode kiosque via le compositeur Wayland minimal **cage**. + +```bash +sudo apt install -y \ + cage \ + chromium-browser \ + xdg-utils \ + fonts-liberation \ + fonts-noto \ + dbus-user-session \ + libcap2-bin +``` + +### Auto-login console + +```bash +sudo raspi-config +# → System Options → Boot / Auto Login → Console Autologin +``` + +### Service kiosk systemd + +Ce service démarre cage + Chromium automatiquement au boot, après que photobooth-app soit prêt. + +```bash +sudo nano /etc/systemd/system/photobooth-kiosk.service +``` + +```ini +[Unit] +Description=Photobooth Kiosk (cage + Chromium) +After=photobooth-app.service network.target +Wants=photobooth-app.service + +[Service] +Type=simple +User=pi +PAMName=login +TTYPath=/dev/tty1 +StandardInput=tty +Environment=XDG_RUNTIME_DIR=/run/user/1000 +Environment=WAYLAND_DISPLAY=wayland-1 + +# Attendre que photobooth-app soit prêt +ExecStartPre=/bin/sleep 8 +ExecStart=/usr/bin/cage -- /usr/bin/chromium-browser \ + --kiosk \ + --noerrdialogs \ + --disable-infobars \ + --disable-session-crashed-bubble \ + --disable-features=TranslateUI \ + --no-first-run \ + --disable-restore-session-state \ + --autoplay-policy=no-user-gesture-required \ + http://localhost:8000 + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable photobooth-kiosk +``` + +> **Note :** cage est un compositeur Wayland "single-app" — il lance Chromium et rien d'autre. +> Pas de gestionnaire de fenêtres, pas de barre des tâches, pas de bureau. +> C'est exactement ce qu'on veut pour un kiosque. + +--- + +## Étape 5 — Activer la caméra + +```bash +# Installer picamera2 et les outils libcamera (paquets système) +sudo apt install -y python3-picamera2 libcamera-apps + +# Vérifier que la caméra est détectée +libcamera-hello --list-cameras +# Doit afficher : "Available cameras" avec le Sony IMX708 +``` + +--- + +## Étape 6 — Installer photobooth-app + +photobooth-app est l'application kiosque qui gère la caméra, le décompte et les frames. + +```bash +# Créer le dossier de données +mkdir -p /home/pi/photobooth-data/{media/processed_full,userdata,script} + +# Installer dans un venv avec accès aux paquets système (nécessaire pour picamera2) +sudo apt install -y python3-venv +python3 -m venv /home/pi/photobooth-venv --system-site-packages +/home/pi/photobooth-venv/bin/pip install photobooth-app +``` + +### Créer le service systemd pour photobooth-app + +```bash +sudo nano /etc/systemd/system/photobooth-app.service +``` + +```ini +[Unit] +Description=Photobooth App +After=network.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi +ExecStart=/home/pi/photobooth-venv/bin/photobooth +Restart=always +RestartSec=5 +Environment=HOME=/home/pi + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable photobooth-app +sudo systemctl start photobooth-app + +# Attendre ~10s puis vérifier +sudo systemctl status photobooth-app +``` + +photobooth-app est accessible sur `http://localhost:8000`. + +### Configurer photobooth-app + +Accéder à l'interface d'administration : `http://:8000/admin` + +**Réglages à faire impérativement :** + +#### a) Backend caméra + +`Settings → Backends → Add / Edit` : +- Backend type : **Picamera2** +- Camera num : **0** +- Capture resolution : **4608 × 2592** *(mode natif IMX708 — 14 fps)* +- Preview resolution : **2304 × 1296** *(mode natif — 56 fps, champ complet)* +- Liveview resolution : **960 × 540** *(min. height=500 imposé par photobooth-app, 16:9)* +- Framerate still mode : **14** *(max en full res)* + +#### b) Désactiver les GPIO internes de photobooth-app + +`Settings → Hardware → GPIO` → **désactiver** (JH Photomaton gère ses propres GPIO) + +``` +gpio_enabled: false +``` + +#### c) Plugin commander — connecter à JH Photomaton + +Copier la config depuis le dépôt après l'avoir cloné (étape 7) : + +```bash +# Après avoir cloné le dépôt JH Photomaton : +cp /home/pi/jh-photomaton/photobooth-app/config/plugin_commander_jh.json \ + ~/.config/photobooth-app/plugin_commander.json + +sudo systemctl restart photobooth-app +``` + +#### d) Bouton "Demande d'impression" + +Dans photobooth-app `Settings → Share → Actions` : +Remplacer la share_command : + +``` +curl http://127.0.0.1:8090/api/print/request?filename={filename} +``` + +> ⚠️ L'ancienne valeur pointait vers Node-RED (`:1880`). Bien remplacer par `:8090`. + +--- + +## Étape 7 — Cloner et installer JH Photomaton + +```bash +# Cloner depuis Gitea +cd /home/pi +git clone https://gitea.lespatas.ovh/admin/photoBooth.git jh-photomaton +cd jh-photomaton + +# Lancer l'installation (crée le venv, installe les dépendances, active le service) +sudo bash scripts/install.sh +``` + +Le script `install.sh` fait automatiquement : +- `apt install` des dépendances système +- Création du venv Python avec `--system-site-packages` +- `pip install -r requirements.txt` +- `pip install rpi-ws281x` (LEDs) +- Copie du service systemd + `systemctl enable` + +### Vérifier l'installation + +```bash +sudo systemctl status jh-photomaton +sudo journalctl -u jh-photomaton -f +``` + +Interface admin : `http://:8090/admin` +Mot de passe : **PhotoBooth2026!** + +--- + +## Étape 8 — Configurer settings.yaml + +```bash +nano /home/pi/jh-photomaton/config/settings.yaml +``` + +Points à vérifier / adapter : + +```yaml +app: + port: 8090 + admin_password: "PhotoBooth2026!" # changer si voulu + +photobooth: + base_url: "http://localhost:8000" + data_dir: "/home/pi/photobooth-data" + media_dir: "/home/pi/photobooth-data/media/processed_full" + +event: + name: "Evenement" # sera modifié depuis l'admin + slug: "evenement" + +leds: + pin: 18 + count: 35 # nombre de LEDs dans l'anneau + brightness: 180 # 0-255, luminosité normale + effects: + capture: + color: [255, 200, 80] # blanc chaud (évite la dominante bleue WS2812b) + flashes: 2 + flash_duration: 0.30 + +button: + pin: 23 # GPIO du bouton arcade + relay_pin: 12 # GPIO du relay + print_enabled: true + +print: + mode: "validation" + printers: + - name: "Selphy_Blanche_WiFi" + label: "Selphy Blanche (WiFi)" + - name: "Selphy_Noire_WiFi" + label: "Selphy Noire (WiFi)" +``` + +Après modification : + +```bash +sudo systemctl restart jh-photomaton +``` + +--- + +## Étape 9 — Installer CUPS et les imprimantes Selphy + +```bash +# Installer CUPS + pilotes Gutenprint +sudo apt install -y cups printer-driver-gutenprint + +# Ajouter l'utilisateur pi au groupe lpadmin +sudo usermod -aG lpadmin pi + +# Démarrer CUPS +sudo systemctl enable cups +sudo systemctl start cups + +# Autoriser l'accès distant à l'interface CUPS +sudo cupsctl --remote-admin +sudo systemctl restart cups +``` + +Accéder à CUPS : `http://:631` + +### Ajouter les imprimantes + +`Administration → Add Printer` pour chaque Selphy : + +| Champ | Valeur | +|-------|--------| +| Connection | `socket://192.168.X.X:9100` (IP de la Selphy sur le WiFi) ou USB | +| Name | `Selphy_Blanche_WiFi` *(exactement ce nom — utilisé par le script)* | +| Driver | Canon SELPHY CP1300 — Gutenprint | +| Media | Postcard 100×148mm | + +Répéter pour `Selphy_Noire_WiFi`. + +### Script d'impression + +Vérifier que le script est exécutable et pointe vers les bons noms d'imprimantes : + +```bash +ls -la /home/pi/photobooth-data/script/script_print.sh +chmod +x /home/pi/photobooth-data/script/script_print.sh + +# Test manuel +/home/pi/photobooth-data/script/script_print.sh \ + "/home/pi/photobooth-data/media/processed_full/test.jpg" \ + "image" "test" "1" +# Doit retourner : PRINTED:Selphy_Blanche_WiFi:1 +``` + +--- + +## Étape 10 — Point d'accès WiFi (hostapd + dnsmasq) + +> **Pourquoi pas RaspAP ?** +> RaspAP installe lighttpd + PHP + une interface web — ~40 Mo de RAM pour une UI qu'on n'utilise pas. +> On configure directement hostapd et dnsmasq, ce qu'il y a en dessous. + +```bash +sudo apt install -y hostapd dnsmasq +sudo systemctl unmask hostapd +``` + +### IP statique sur wlan0 + +Ajouter dans `/etc/dhcpcd.conf` : + +```ini +interface wlan0 + static ip_address=192.168.4.1/24 + nohook wpa_supplicant +``` + +### hostapd — point d'accès WiFi ouvert + +```bash +sudo nano /etc/hostapd/hostapd.conf +``` + +```ini +interface=wlan0 +driver=nl80211 +ssid=Photomaton-LSDW +hw_mode=g +channel=6 +wmm_enabled=0 +macaddr_acl=0 + +# WiFi ouvert — pas de mot de passe +auth_algs=1 +ignore_broadcast_ssid=0 +``` + +Déclarer le fichier de config dans `/etc/default/hostapd` : + +```ini +DAEMON_CONF="/etc/hostapd/hostapd.conf" +``` + +### dnsmasq — DHCP + redirection DNS (portail captif) + +Ajouter à la fin de `/etc/dnsmasq.conf` : + +```ini +interface=wlan0 +bind-interfaces + +# DHCP : distribue des IPs aux clients WiFi +dhcp-range=192.168.4.2,192.168.4.100,255.255.255.0,24h +# Annonce le Pi comme serveur DNS aux clients +dhcp-option=6,192.168.4.1 + +# ── Portail captif ────────────────────────────────────────────── +# Tout le trafic DNS → Pi (192.168.4.1) +# google.com, instagram.com, lessapinsduweb.com... tout arrive ici. +# Zoraxy fait le tri par HTTP Host header : +# - *.lessapinsduweb.com → apps configurées dans Zoraxy +# - tout le reste → redirect vers photomaton.lessapinsduweb.com +address=/#/192.168.4.1 + +# Sondes de détection portail captif — iOS et Android ouvrent +# automatiquement le navigateur sur la galerie +address=/captive.apple.com/192.168.4.1 +address=/connectivitycheck.gstatic.com/192.168.4.1 +address=/detectportal.firefox.com/192.168.4.1 +address=/www.msftconnecttest.com/192.168.4.1 +``` + +> **Note :** `address=/#/192.168.4.1` s'applique uniquement aux clients WiFi +> (DHCP sur wlan0). Le Pi lui-même utilise le DNS de son routeur via eth0. + +### Activer les services + +```bash +sudo systemctl enable --now hostapd +sudo systemctl enable --now dnsmasq +``` + +--- + +## Étape 11 — Installer Zoraxy (reverse proxy HTTPS + catch-all) + +Zoraxy sert deux rôles ici : reverse proxy HTTPS pour les apps, et redirection +captive portal pour tout le trafic inconnu des clients WiFi. + +```bash +# Télécharger le binaire ARM64 +mkdir ~/zoraxy && cd ~/zoraxy +wget https://github.com/tobychui/zoraxy/releases/download/v3.3.2/zoraxy_linux_arm64 -O zoraxy +chmod +x zoraxy + +# Service systemd +sudo nano /etc/systemd/system/zoraxy.service +``` + +```ini +[Unit] +Description=Zoraxy Reverse Proxy +After=network.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi/zoraxy +ExecStart=/home/pi/zoraxy/zoraxy -port=8888 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable zoraxy +sudo systemctl start zoraxy +``` + +### Configurer les routes dans Zoraxy + +| Hostname | Destination | Notes | +|----------|-------------|-------| +| `photomaton.lessapinsduweb.com` | `127.0.0.1:8000` | Galerie publique / kiosque | +| `admin.lessapinsduweb.com` | `127.0.0.1:8090` | Interface admin JH Photomaton | +| `cups.lessapinsduweb.com` | `127.0.0.1:631` | Optionnel | +| **`*` (catch-all)** | **Redirect → `http://photomaton.lessapinsduweb.com`** | **Portail captif** | + +La règle catch-all est la clé : quand un client WiFi tape `google.com`, DNS +résout vers `192.168.4.1`, Zoraxy reçoit la requête, ne reconnaît pas l'hôte, +et redirige vers la galerie photo. + +> **Limite HTTPS :** si un client tape `https://google.com`, le navigateur voit +> un certificat invalide et bloque avant la redirection. C'est inhérent à tout +> portail DNS captif. En pratique : QR code sur les tables + iOS/Android qui +> détectent le portail automatiquement via les sondes dnsmasq ci-dessus. + +--- + +## Étape 12 — Copier les données de l'ancienne carte SD + +Depuis ton PC, copier les fichiers importants de l'ancienne carte : + +```bash +# Monter l'ancienne carte SD sur le PC (remplacer /dev/sdX) +# ou utiliser un lecteur de carte + +# Cadres (frames) photobooth +rsync -av /mnt/old-sd/home/pi/photobooth-data/userdata/ \ + pi@:/home/pi/photobooth-data/userdata/ + +# Script d'impression +rsync -av /mnt/old-sd/home/pi/photobooth-data/script/ \ + pi@:/home/pi/photobooth-data/script/ + +# Config photobooth-app (actions, frames configurées) +rsync -av /mnt/old-sd/home/pi/.config/photobooth-app/ \ + pi@:/home/pi/.config/photobooth-app/ +# ⚠️ Après cette copie, vérifier que plugin_commander.json pointe bien vers :8090 (pas :1880) + +# Config JH Photomaton (settings.yaml + base SQLite) +rsync -av /mnt/old-sd/home/pi/jh-photomaton/config/settings.yaml \ + pi@:/home/pi/jh-photomaton/config/ +rsync -av /mnt/old-sd/home/pi/jh-photomaton/data/ \ + pi@:/home/pi/jh-photomaton/data/ +``` + +--- + +## Étape 13 — Vérification finale + +```bash +# Tous les services actifs ? +sudo systemctl status photobooth-app jh-photomaton cups + +# Logs JH Photomaton +sudo journalctl -u jh-photomaton -f + +# Tests API rapides +curl http://localhost:8090/api/event # événement en cours +curl http://localhost:8090/api/system/stats # CPU/RAM/temp + +# Test LED +curl -X POST "http://localhost:8090/api/leds/play?effect=capture" + +# Test webhook photobooth (simule une photo prise) +curl "http://localhost:8090/api/webhook/photobooth?event_key=capture" +curl "http://localhost:8090/api/webhook/photobooth?event_key=finished" + +# Imprimantes disponibles +lpstat -p +``` + +Interface admin : `http://:8090/admin` + +--- + +## Résumé des ports + +| Service | Port | Accès | +|---------|------|-------| +| JH Photomaton (admin) | 8090 | `http://admin.lessapinsduweb.com` ou `http://:8090/admin` | +| photobooth-app (kiosque) | 8000 | `http://photomaton.lessapinsduweb.com` | +| CUPS (imprimantes) | 631 | `http://:631` (réseau local uniquement) | +| Zoraxy (proxy admin) | 8888 | `http://192.168.4.1:8888` | +| WiFi hotspot | — | SSID `Photomaton-LSDW` — IP Pi : `192.168.4.1` | + +--- + +## Points d'attention post-installation + +1. **plugin_commander.json** — vérifier que la URL pointe vers `:8090` (pas l'ancien Node-RED `:1880`) +2. **share_command dans photobooth-app** — même chose, changer `:1880` → `:8090/api/print/request` +3. **Nom des imprimantes CUPS** — doit correspondre exactement à ce qui est dans `settings.yaml` +4. **GPIO18 / audio** — `dtparam=audio=off` dans `/boot/firmware/config.txt` est obligatoire pour les LEDs +5. **Couleur du flash** — le réglage warm white `[255, 200, 80]` est dans `settings.yaml` → ajustable depuis l'admin dans Réglages → Flash photo +6. **Zoraxy catch-all** — ne pas oublier la règle `*` → redirect `photomaton.lessapinsduweb.com`, c'est elle qui fait le portail captif +7. **DAEMON_CONF hostapd** — sans cette ligne dans `/etc/default/hostapd`, hostapd démarre mais ignore la config (SSID invisible) + +--- + +## CI/CD (déploiement automatique depuis Gitea) + +Voir `docs/CI-CD-SETUP.md` pour la configuration complète du pipeline Gitea Actions. + +En résumé : +```bash +# Sur le Pi — générer la clé SSH pour le CI +ssh-keygen -t ed25519 -C "gitea-cicd" -f ~/.ssh/gitea_deploy -N "" +cat ~/.ssh/gitea_deploy.pub >> ~/.ssh/authorized_keys + +# Installer la règle sudoers +sudo cp /home/pi/jh-photomaton/scripts/sudoers-jh-photomaton /etc/sudoers.d/jh-photomaton +sudo chmod 440 /etc/sudoers.d/jh-photomaton +``` + +Puis dans Gitea → Settings → Secrets : ajouter `PI_SSH_HOST`, `PI_SSH_USER`, `PI_SSH_KEY`. diff --git a/frontend/templates/admin/dashboard.html b/frontend/templates/admin/dashboard.html index c36cf35..235c391 100644 --- a/frontend/templates/admin/dashboard.html +++ b/frontend/templates/admin/dashboard.html @@ -6,6 +6,7 @@ Galerie admin Impression Actions +Réglages Galerie publique Déconnexion {% endblock %} @@ -53,6 +54,28 @@ + +
+
🎫 Événement en cours
+
+
+ + + +
+
+ 📸 photos + ⬇️ téléchargements + 🖨 impressions +
+
+
+
+
@@ -274,8 +297,37 @@ function onWsMessage(msg) { } } +// ── Événement ───────────────────────────────────────────────────────────────── +async function loadEvent() { + try { + const e = await api('GET', '/api/event'); + document.getElementById('event-name-input').value = e.name || ''; + const s = e.stats || {}; + document.getElementById('ev-photos').textContent = s.photos_taken ?? '0'; + document.getElementById('ev-downloads').textContent = s.downloads ?? '0'; + document.getElementById('ev-prints').textContent = s.prints_done ?? '0'; + document.getElementById('event-slug-display').textContent = + `Fichiers : ${e.slug}_By_LSDW_YYYY-MM-DD.jpg · Démarré le ${ + e.started_at ? new Date(e.started_at * 1000).toLocaleDateString('fr-FR') : '—' + }`; + } catch(err) { console.warn('Événement:', err); } +} + +async function saveEvent(isNew) { + const name = document.getElementById('event-name-input').value.trim(); + if (!name) { showToast('Entrez un nom d\'événement', 'error'); return; } + if (isNew && !confirm(`Terminer l'événement actuel et démarrer "${name}" ?\n\nLes statistiques de l'ancien événement seront archivées.`)) return; + try { + const r = await api('PUT', '/api/event', {name, new_event: isNew}); + showToast(isNew ? `↻ Nouvel événement : ${r.name}` : `✅ Événement : ${r.name}`, 'success'); + loadEvent(); + } catch(err) { showToast('Erreur sauvegarde événement', 'error'); } +} + // ── Init ────────────────────────────────────────────────────────────────────── loadQueue(); +loadEvent(); setInterval(loadQueue, 15000); +setInterval(loadEvent, 60000); {% endblock %} diff --git a/frontend/templates/admin/gallery.html b/frontend/templates/admin/gallery.html index 7b7e126..c737613 100644 --- a/frontend/templates/admin/gallery.html +++ b/frontend/templates/admin/gallery.html @@ -6,45 +6,199 @@ Galerie admin Impression Actions +Réglages Galerie publique Déconnexion {% endblock %} +{% block head %} + +{% endblock %} + {% block content %} -
-
-

Galerie — Administration

-
+