all
This commit is contained in:
@@ -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)
|
@router.get("/admin/print", response_class=HTMLResponse)
|
||||||
async def admin_print(request: Request):
|
async def admin_print(request: Request):
|
||||||
redirect = _require_auth(request)
|
redirect = _require_auth(request)
|
||||||
|
|||||||
@@ -1,98 +1,15 @@
|
|||||||
"""API galerie admin — impression et suppression de photos."""
|
"""API galerie admin — impression et suppression de photos."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Request, Query
|
from fastapi import APIRouter, Request, Query
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/api/gallery/photos")
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
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:
|
def _is_image(item: dict) -> bool:
|
||||||
t = item.get("type", item.get("mediaitem_type", "image"))
|
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", ""))))
|
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."""
|
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
|
||||||
from pathlib import Path
|
|
||||||
base = Path(media_dir)
|
base = Path(media_dir)
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# L'ID peut être le stem du filename
|
|
||||||
for ext in (".jpg", ".jpeg", ".png"):
|
for ext in (".jpg", ".jpeg", ".png"):
|
||||||
f = base / f"{photo_id}{ext}"
|
f = base / f"{photo_id}{ext}"
|
||||||
if f.exists():
|
if f.exists():
|
||||||
return f
|
return f
|
||||||
# Cherche dans les sous-dossiers
|
|
||||||
matches = list(base.rglob(f"{photo_id}{ext}"))
|
matches = list(base.rglob(f"{photo_id}{ext}"))
|
||||||
if matches:
|
if matches:
|
||||||
return matches[0]
|
return matches[0]
|
||||||
return None
|
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}
|
||||||
|
|||||||
@@ -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}
|
||||||
+39
-14
@@ -1,12 +1,15 @@
|
|||||||
"""Galerie publique — accessible sans authentification."""
|
"""Galerie publique -- accessible sans authentification."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Request, Query
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from pathlib import Path
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -15,7 +18,6 @@ _templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "fr
|
|||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
"""Page d'accueil → redirige vers la galerie publique."""
|
|
||||||
return RedirectResponse(url="/gallery")
|
return RedirectResponse(url="/gallery")
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ async def index(request: Request):
|
|||||||
async def gallery_page(request: Request):
|
async def gallery_page(request: Request):
|
||||||
cfg = request.app.state.config
|
cfg = request.app.state.config
|
||||||
if not cfg.gallery.public_enabled:
|
if not cfg.gallery.public_enabled:
|
||||||
return HTMLResponse("<h1>Galerie désactivée</h1>", status_code=403)
|
return HTMLResponse("<h1>Galerie desactivee</h1>", status_code=403)
|
||||||
return _templates.TemplateResponse("public/gallery.html", {"request": request, "config": cfg})
|
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),
|
page: int = Query(default=1, ge=1),
|
||||||
limit: int = Query(default=24, ge=1, le=100),
|
limit: int = Query(default=24, ge=1, le=100),
|
||||||
):
|
):
|
||||||
"""Liste des photos depuis photobooth-app (paginée)."""
|
|
||||||
pb = request.app.state.photobooth_service
|
pb = request.app.state.photobooth_service
|
||||||
cfg = request.app.state.config
|
|
||||||
|
|
||||||
all_photos = await pb.get_media_collection(limit=500)
|
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)]
|
photos = [p for p in all_photos if _is_image(p)]
|
||||||
|
|
||||||
total = len(photos)
|
total = len(photos)
|
||||||
@@ -47,7 +45,6 @@ async def api_gallery_photos(
|
|||||||
end = start + limit
|
end = start + limit
|
||||||
page_photos = photos[start:end]
|
page_photos = photos[start:end]
|
||||||
|
|
||||||
# Enrichit avec les URLs
|
|
||||||
for p in page_photos:
|
for p in page_photos:
|
||||||
pid = _get_id(p)
|
pid = _get_id(p)
|
||||||
p["full_url"] = pb.media_url(pid)
|
p["full_url"] = pb.media_url(pid)
|
||||||
@@ -64,9 +61,37 @@ async def api_gallery_photos(
|
|||||||
|
|
||||||
@router.get("/api/gallery/download/{photo_id}")
|
@router.get("/api/gallery/download/{photo_id}")
|
||||||
async def download_photo(request: Request, photo_id: str):
|
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
|
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:
|
def _is_image(item: dict) -> bool:
|
||||||
|
|||||||
@@ -49,3 +49,52 @@ async def leds_off(request: Request):
|
|||||||
led = request.app.state.led_service
|
led = request.app.state.led_service
|
||||||
led.play("off")
|
led.play("off")
|
||||||
return {"ok": True}
|
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}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""API gestion de la file d'attente d'impression."""
|
"""API gestion de la file d'attente d'impression."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ async def print_request(
|
|||||||
|
|
||||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
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
|
# Feedback LED si impression directe
|
||||||
if request.app.state.config.print.mode == "direct":
|
if request.app.state.config.print.mode == "direct":
|
||||||
led.play("printing")
|
led.play("printing")
|
||||||
@@ -71,6 +78,10 @@ async def execute_print(
|
|||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
led.play("finished")
|
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:
|
else:
|
||||||
led.play("error")
|
led.play("error")
|
||||||
|
|
||||||
@@ -112,3 +123,40 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
|
|||||||
config_svc.save_print_mode(mode)
|
config_svc.save_print_mode(mode)
|
||||||
request.app.state.config.print.mode = mode
|
request.app.state.config.print.mode = mode
|
||||||
return {"ok": True, "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}
|
||||||
|
|||||||
+111
-1
@@ -1,8 +1,10 @@
|
|||||||
"""API de surveillance des ressources système."""
|
"""API de surveillance des ressources système."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Request
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Request, Body
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -50,3 +52,111 @@ async def control_relay(request: Request, state: str = "on"):
|
|||||||
else:
|
else:
|
||||||
btn.relay_off()
|
btn.relay_off()
|
||||||
return {"ok": True, "relay": state}
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+8
-40
@@ -1,16 +1,6 @@
|
|||||||
"""Webhooks reçus de photobooth-app via plugin_commander.
|
"""Webhooks recus 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 asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Query
|
from fastapi import APIRouter, Request, Query
|
||||||
@@ -18,7 +8,6 @@ from fastapi import APIRouter, Request, Query
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Durée par défaut du countdown (en secondes) — peut être overridé par action
|
|
||||||
DEFAULT_COUNTDOWN = 5.0
|
DEFAULT_COUNTDOWN = 5.0
|
||||||
|
|
||||||
|
|
||||||
@@ -33,30 +22,25 @@ async def photobooth_webhook(
|
|||||||
ws = request.app.state.ws_manager
|
ws = request.app.state.ws_manager
|
||||||
|
|
||||||
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
|
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})
|
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
|
||||||
|
|
||||||
match event_key:
|
match event_key:
|
||||||
case "counting":
|
case "counting":
|
||||||
# Countdown en cours — remplissage LED vert
|
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
||||||
# 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":
|
case "capture":
|
||||||
# Flash photo
|
|
||||||
led.play("capture")
|
led.play("capture")
|
||||||
|
|
||||||
case "captured":
|
case "captured":
|
||||||
# Photo prise, traitement en cours
|
|
||||||
led.play("captured")
|
led.play("captured")
|
||||||
|
|
||||||
case "finished":
|
case "finished":
|
||||||
# Traitement terminé
|
|
||||||
led.play("finished")
|
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))
|
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":
|
case "start":
|
||||||
led.play("idle")
|
led.play("idle")
|
||||||
@@ -65,28 +49,12 @@ async def photobooth_webhook(
|
|||||||
led.play("off")
|
led.play("off")
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
logger.debug("Événement inconnu: %s", event_key)
|
logger.debug("Evenement inconnu: %s", event_key)
|
||||||
|
|
||||||
return {"ok": True, "event": event_key}
|
return {"ok": True, "event": event_key}
|
||||||
|
|
||||||
|
|
||||||
async def _delayed_relay_on(btn, delay: float):
|
async def _delayed_relay_on(btn, delay: float):
|
||||||
import asyncio
|
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
if btn:
|
if btn:
|
||||||
btn.relay_on()
|
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
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ButtonService:
|
|||||||
self._click_count = 0
|
self._click_count = 0
|
||||||
self._press_time: float = 0.0
|
self._press_time: float = 0.0
|
||||||
self._click_timer: threading.Timer | None = None
|
self._click_timer: threading.Timer | None = None
|
||||||
|
self._long_timer: threading.Timer | None = None # ← fix: init explicite
|
||||||
self._long_press_fired = False
|
self._long_press_fired = False
|
||||||
self._relay_enabled = True
|
self._relay_enabled = True
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -110,6 +111,33 @@ class ButtonService:
|
|||||||
def simulate_long_press(self):
|
def simulate_long_press(self):
|
||||||
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
|
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 ────────────────────────────────────────────────────────
|
# ── Callbacks GPIO ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_pressed(self):
|
def _on_pressed(self):
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class PhotoboothConfig:
|
|||||||
config_file: str = "/home/pi/.config/photobooth-app/config.json"
|
config_file: str = "/home/pi/.config/photobooth-app/config.json"
|
||||||
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
|
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
|
||||||
userdata_dir: str = "/home/pi/photobooth-data/userdata"
|
userdata_dir: str = "/home/pi/photobooth-data/userdata"
|
||||||
|
show_delete_button: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -89,6 +90,13 @@ class GalleryConfig:
|
|||||||
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
|
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventConfig:
|
||||||
|
name: str = "Evenement"
|
||||||
|
slug: str = "evenement"
|
||||||
|
started_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
app: AppConfig = field(default_factory=AppConfig)
|
app: AppConfig = field(default_factory=AppConfig)
|
||||||
@@ -97,6 +105,7 @@ class Config:
|
|||||||
leds: LEDConfig = field(default_factory=LEDConfig)
|
leds: LEDConfig = field(default_factory=LEDConfig)
|
||||||
print: PrintConfig = field(default_factory=PrintConfig)
|
print: PrintConfig = field(default_factory=PrintConfig)
|
||||||
gallery: GalleryConfig = field(default_factory=GalleryConfig)
|
gallery: GalleryConfig = field(default_factory=GalleryConfig)
|
||||||
|
event: EventConfig = field(default_factory=EventConfig)
|
||||||
button_actions: dict = field(default_factory=dict)
|
button_actions: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +116,7 @@ class ConfigService:
|
|||||||
|
|
||||||
def load(self) -> Config:
|
def load(self) -> Config:
|
||||||
if not self.path.exists():
|
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()
|
self._config = Config()
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
@@ -143,30 +152,60 @@ class ConfigService:
|
|||||||
if "gallery" in raw:
|
if "gallery" in raw:
|
||||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
|
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", {
|
cfg.button_actions = raw.get("button_actions", {
|
||||||
1: {"label": "Photo normale", "photobooth_index": 0},
|
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},
|
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
|
self._config = cfg
|
||||||
logger.info("Configuration chargée depuis %s", self.path)
|
logger.info("Configuration chargee depuis %s", self.path)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
def save_button_actions(self, button_actions: dict):
|
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:
|
with open(self.path, encoding="utf-8") as f:
|
||||||
raw = yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
raw["button_actions"] = button_actions
|
raw["button_actions"] = button_actions
|
||||||
|
|
||||||
with open(self.path, "w", encoding="utf-8") as f:
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
|
||||||
if self._config:
|
if self._config:
|
||||||
self._config.button_actions = button_actions
|
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):
|
def save_print_mode(self, mode: str):
|
||||||
with open(self.path, encoding="utf-8") as f:
|
with open(self.path, encoding="utf-8") as f:
|
||||||
raw = yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
@@ -181,3 +220,12 @@ class ConfigService:
|
|||||||
if self._config is None:
|
if self._config is None:
|
||||||
self.load()
|
self.load()
|
||||||
return self._config
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem)
|
||||||
Mode mock : log des opérations uniquement (développement)
|
Mode mock : log uniquement (developpement)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -16,13 +17,12 @@ from backend.services.config_service import Config, LEDConfig
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
|
|
||||||
try:
|
try:
|
||||||
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
|
from rpi_ws281x import PixelStrip, Color as WS_Color
|
||||||
HAS_WS281X = True
|
HAS_WS281X = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_WS281X = False
|
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
|
class WS_Color: # type: ignore
|
||||||
def __init__(self, r: int, g: int, b: int):
|
def __init__(self, r: int, g: int, b: int):
|
||||||
@@ -31,7 +31,7 @@ except ImportError:
|
|||||||
return f"Color({self.r},{self.g},{self.b})"
|
return f"Color({self.r},{self.g},{self.b})"
|
||||||
|
|
||||||
class PixelStrip: # type: ignore
|
class PixelStrip: # type: ignore
|
||||||
def __init__(self, *args, **kwargs): pass
|
def __init__(self, *a, **kw): pass
|
||||||
def begin(self): pass
|
def begin(self): pass
|
||||||
def show(self): pass
|
def show(self): pass
|
||||||
def setPixelColor(self, i, c): pass
|
def setPixelColor(self, i, c): pass
|
||||||
@@ -40,16 +40,10 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def _color(rgb: list[int]) -> WS_Color:
|
def _color(rgb: list[int]) -> WS_Color:
|
||||||
return WS_Color(rgb[0], rgb[1], rgb[2])
|
return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
||||||
|
|
||||||
|
|
||||||
def _lerp(a: int, b: int, t: float) -> int:
|
|
||||||
return int(a + (b - a) * t)
|
|
||||||
|
|
||||||
|
|
||||||
class LEDService:
|
class LEDService:
|
||||||
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
|
|
||||||
|
|
||||||
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
||||||
"printing", "error", "disabled", "off")
|
"printing", "error", "disabled", "off")
|
||||||
|
|
||||||
@@ -74,16 +68,16 @@ class LEDService:
|
|||||||
self._cfg.pin,
|
self._cfg.pin,
|
||||||
self._cfg.freq_hz,
|
self._cfg.freq_hz,
|
||||||
self._cfg.dma,
|
self._cfg.dma,
|
||||||
False, # invert
|
False,
|
||||||
self._cfg.brightness,
|
self._cfg.brightness,
|
||||||
0, # channel
|
0,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self._strip.begin()
|
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:
|
except Exception as e:
|
||||||
logger.error("Erreur init strip LED: %s", e)
|
logger.error("Erreur init strip LED: %s", e)
|
||||||
self._strip = PixelStrip() # fallback mock
|
self._strip = PixelStrip()
|
||||||
else:
|
else:
|
||||||
self._strip = PixelStrip()
|
self._strip = PixelStrip()
|
||||||
|
|
||||||
@@ -104,10 +98,9 @@ class LEDService:
|
|||||||
def set_on_change(self, cb: Callable):
|
def set_on_change(self, cb: Callable):
|
||||||
self._on_change_cb = cb
|
self._on_change_cb = cb
|
||||||
|
|
||||||
# ── Commandes publiques (thread-safe) ────────────────────────────────────
|
# ── Commandes publiques ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def play(self, effect: str, countdown_duration: float = 5.0):
|
def play(self, effect: str, countdown_duration: float = 5.0):
|
||||||
"""Joue un effet nommé. Thread-safe."""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._current_effect = effect
|
self._current_effect = effect
|
||||||
self._countdown_duration = countdown_duration
|
self._countdown_duration = countdown_duration
|
||||||
@@ -116,7 +109,6 @@ class LEDService:
|
|||||||
self._notify_change(effect)
|
self._notify_change(effect)
|
||||||
|
|
||||||
def set_color(self, r: int, g: int, b: int):
|
def set_color(self, r: int, g: int, b: int):
|
||||||
"""Couleur fixe immédiate."""
|
|
||||||
self._fill(WS_Color(r, g, b))
|
self._fill(WS_Color(r, g, b))
|
||||||
|
|
||||||
# ── Thread principal ──────────────────────────────────────────────────────
|
# ── Thread principal ──────────────────────────────────────────────────────
|
||||||
@@ -143,19 +135,16 @@ class LEDService:
|
|||||||
fn = effect_func.get(current, self._effect_idle)
|
fn = effect_func.get(current, self._effect_idle)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if current in ("countdown",):
|
if current == "countdown":
|
||||||
fn(cd_dur)
|
fn(cd_dur)
|
||||||
else:
|
else:
|
||||||
fn()
|
fn()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Erreur effet LED '%s': %s", current, 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():
|
if not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._current_effect == current:
|
if self._current_effect == current:
|
||||||
# Retour automatique à idle après effets ponctuels
|
|
||||||
if current in ("capture", "captured", "finished", "error"):
|
if current in ("capture", "captured", "finished", "error"):
|
||||||
self._current_effect = "idle"
|
self._current_effect = "idle"
|
||||||
self._cmd_event.wait(timeout=0.1)
|
self._cmd_event.wait(timeout=0.1)
|
||||||
@@ -163,27 +152,23 @@ class LEDService:
|
|||||||
# ── Effets ───────────────────────────────────────────────────────────────
|
# ── Effets ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _effect_idle(self):
|
def _effect_idle(self):
|
||||||
"""Respiration bleue douce."""
|
|
||||||
cfg = self._cfg.get_effect("idle")
|
cfg = self._cfg.get_effect("idle")
|
||||||
c = cfg.color
|
c = cfg.color
|
||||||
speed = cfg.speed
|
speed = cfg.speed
|
||||||
n = self._cfg.count
|
|
||||||
|
|
||||||
step = 0
|
step = 0
|
||||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
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)
|
brightness = max(0.05, t)
|
||||||
color = WS_Color(
|
self._fill(WS_Color(
|
||||||
int(c[0] * brightness),
|
int(c[0] * brightness),
|
||||||
int(c[1] * brightness),
|
int(c[1] * brightness),
|
||||||
int(c[2] * brightness),
|
int(c[2] * brightness),
|
||||||
)
|
))
|
||||||
self._fill(color)
|
|
||||||
time.sleep(speed)
|
time.sleep(speed)
|
||||||
step += 1
|
step += 1
|
||||||
|
|
||||||
def _effect_countdown(self, duration: float = 5.0):
|
def _effect_countdown(self, duration: float = 5.0):
|
||||||
"""Remplissage progressif vert LED par LED."""
|
|
||||||
cfg = self._cfg.get_effect("countdown")
|
cfg = self._cfg.get_effect("countdown")
|
||||||
c = cfg.color
|
c = cfg.color
|
||||||
n = self._cfg.count
|
n = self._cfg.count
|
||||||
@@ -191,55 +176,68 @@ class LEDService:
|
|||||||
color = WS_Color(c[0], c[1], c[2])
|
color = WS_Color(c[0], c[1], c[2])
|
||||||
|
|
||||||
self._fill(blank)
|
self._fill(blank)
|
||||||
|
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||||
elapsed = time.time() - t_start
|
elapsed = time.time() - t_start
|
||||||
ratio = min(elapsed / duration, 1.0)
|
ratio = min(elapsed / duration, 1.0)
|
||||||
leds_on = int(ratio * n)
|
leds_on = int(ratio * n)
|
||||||
|
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
||||||
self._strip.show()
|
self._strip.show()
|
||||||
|
|
||||||
if ratio >= 1.0:
|
if ratio >= 1.0:
|
||||||
break
|
break
|
||||||
time.sleep(0.04)
|
time.sleep(0.04)
|
||||||
|
|
||||||
def _effect_capture(self):
|
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")
|
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)
|
off = WS_Color(0, 0, 0)
|
||||||
|
|
||||||
|
# Boost luminosite max pendant le flash
|
||||||
|
if HAS_WS281X and self._strip:
|
||||||
|
self._strip.setBrightness(255)
|
||||||
|
|
||||||
|
try:
|
||||||
for _ in range(cfg.flashes):
|
for _ in range(cfg.flashes):
|
||||||
if self._cmd_event.is_set():
|
if self._cmd_event.is_set():
|
||||||
break
|
break
|
||||||
self._fill(white)
|
self._fill(flash_color)
|
||||||
time.sleep(cfg.flash_duration)
|
time.sleep(cfg.flash_duration)
|
||||||
self._fill(off)
|
self._fill(off)
|
||||||
time.sleep(cfg.flash_duration)
|
# 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)
|
||||||
|
|
||||||
def _effect_solid(self):
|
def _effect_solid(self):
|
||||||
"""Couleur pleine selon l'effet courant."""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
current = self._current_effect
|
current = self._current_effect
|
||||||
cfg = self._cfg.get_effect(current)
|
cfg = self._cfg.get_effect(current)
|
||||||
self._fill(WS_Color(*cfg.color))
|
self._fill(WS_Color(*cfg.color))
|
||||||
# Attente jusqu'à prochain cmd
|
|
||||||
self._cmd_event.wait()
|
self._cmd_event.wait()
|
||||||
|
|
||||||
def _effect_finished(self):
|
def _effect_finished(self):
|
||||||
"""Bleu fixe pendant duration secondes, puis retour idle."""
|
|
||||||
cfg = self._cfg.get_effect("finished")
|
cfg = self._cfg.get_effect("finished")
|
||||||
self._fill(WS_Color(*cfg.color))
|
self._fill(WS_Color(*cfg.color))
|
||||||
self._cmd_event.wait(timeout=cfg.duration)
|
self._cmd_event.wait(timeout=cfg.duration)
|
||||||
|
|
||||||
def _effect_spin(self):
|
def _effect_spin(self):
|
||||||
"""Rotation d'une traînée de LEDs."""
|
|
||||||
cfg = self._cfg.get_effect("printing")
|
cfg = self._cfg.get_effect("printing")
|
||||||
c = cfg.color
|
c = cfg.color
|
||||||
n = self._cfg.count
|
n = self._cfg.count
|
||||||
speed = cfg.speed
|
|
||||||
tail = 6
|
tail = 6
|
||||||
|
|
||||||
pos = 0
|
pos = 0
|
||||||
@@ -248,22 +246,18 @@ class LEDService:
|
|||||||
dist = (i - pos) % n
|
dist = (i - pos) % n
|
||||||
if dist < tail:
|
if dist < tail:
|
||||||
factor = (tail - dist) / tail
|
factor = (tail - dist) / tail
|
||||||
self._strip.setPixelColor(
|
self._strip.setPixelColor(i, WS_Color(
|
||||||
i,
|
|
||||||
WS_Color(
|
|
||||||
int(c[0] * factor),
|
int(c[0] * factor),
|
||||||
int(c[1] * factor),
|
int(c[1] * factor),
|
||||||
int(c[2] * factor),
|
int(c[2] * factor),
|
||||||
),
|
))
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
||||||
self._strip.show()
|
self._strip.show()
|
||||||
pos = (pos + 1) % n
|
pos = (pos + 1) % n
|
||||||
time.sleep(speed)
|
time.sleep(cfg.speed)
|
||||||
|
|
||||||
def _effect_error(self):
|
def _effect_error(self):
|
||||||
"""Flash rouge."""
|
|
||||||
cfg = self._cfg.get_effect("error")
|
cfg = self._cfg.get_effect("error")
|
||||||
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||||
off = WS_Color(0, 0, 0)
|
off = WS_Color(0, 0, 0)
|
||||||
|
|||||||
@@ -125,5 +125,42 @@ class PhotoboothService:
|
|||||||
results.append(str(f))
|
results.append(str(f))
|
||||||
return sorted(results)
|
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):
|
async def close(self):
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
|||||||
@@ -100,6 +100,35 @@ class PrinterService:
|
|||||||
async def get_pending(self) -> list[dict]:
|
async def get_pending(self) -> list[dict]:
|
||||||
return await self.get_queue("pending")
|
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 def cancel(self, entry_id: str) -> bool:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
cursor = await self._db.execute(
|
cursor = await self._db.execute(
|
||||||
@@ -186,48 +215,105 @@ class PrinterService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(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 ───────────────────────────────────────────────
|
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_printers_status(self) -> list[dict]:
|
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 = []
|
statuses = []
|
||||||
for p in self._cfg.printers:
|
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({
|
statuses.append({
|
||||||
"name": p["name"],
|
"name": name,
|
||||||
"label": p["label"],
|
"label": self._printer_label(p),
|
||||||
**status,
|
**status,
|
||||||
})
|
})
|
||||||
return statuses
|
return statuses
|
||||||
|
|
||||||
def _get_printer_status(self, printer_name: str) -> dict:
|
def _get_printer_status(self, printer_name: str) -> dict:
|
||||||
|
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
# État de l'imprimante
|
||||||
|
r_state = subprocess.run(
|
||||||
["lpstat", "-p", printer_name],
|
["lpstat", "-p", printer_name],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
output = result.stdout.lower()
|
out = r_state.stdout.lower()
|
||||||
if "idle" in output:
|
|
||||||
|
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"
|
state = "idle"
|
||||||
elif "printing" in output or "processing" in output:
|
elif "printing" in out or "processing" in out:
|
||||||
state = "printing"
|
state = "printing"
|
||||||
elif "disabled" in output:
|
elif "stopped" in out or "disabled" in out:
|
||||||
state = "disabled"
|
state = "disabled"
|
||||||
elif "not found" in output or result.returncode != 0:
|
|
||||||
state = "offline"
|
|
||||||
else:
|
else:
|
||||||
state = "unknown"
|
state = "unknown"
|
||||||
|
|
||||||
# Compte les jobs en attente
|
# Est-ce que l'imprimante accepte les nouveaux jobs ?
|
||||||
jobs_result = subprocess.run(
|
r_accept = subprocess.run(
|
||||||
["lpstat", "-o", printer_name],
|
["lpstat", "-a", printer_name],
|
||||||
capture_output=True, text=True, timeout=5
|
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:
|
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:
|
async def cancel_cups_jobs(self, printer_name: str) -> bool:
|
||||||
"""Annule tous les jobs CUPS pour une imprimante."""
|
"""Annule tous les jobs CUPS pour une imprimante."""
|
||||||
@@ -240,3 +326,43 @@ class PrinterService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Erreur cancel CUPS: %s", e)
|
logger.error("Erreur cancel CUPS: %s", e)
|
||||||
return False
|
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
|
||||||
|
|||||||
+39
-18
@@ -1,5 +1,5 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# JH Photomaton — Configuration principale
|
# JH Photomaton -- Configuration principale
|
||||||
# Association Les Sapins Du Web (LSDW)
|
# Association Les Sapins Du Web (LSDW)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@@ -11,30 +11,37 @@ app:
|
|||||||
secret_key: "photomaton-jh-2026-secret-change-me"
|
secret_key: "photomaton-jh-2026-secret-change-me"
|
||||||
admin_password: "PhotoBooth2026!"
|
admin_password: "PhotoBooth2026!"
|
||||||
|
|
||||||
# --- Intégration photobooth-app ---
|
# --- Integration photobooth-app ---
|
||||||
photobooth:
|
photobooth:
|
||||||
base_url: "http://localhost:8083"
|
base_url: "http://localhost:8000"
|
||||||
data_dir: "/home/pi/photobooth-data"
|
data_dir: "/home/pi/photobooth-data"
|
||||||
config_file: "/home/pi/.config/photobooth-app/config.json"
|
config_file: "/home/pi/.config/photobooth-app/config.json"
|
||||||
media_dir: "/home/pi/photobooth-data/media/processed_full"
|
media_dir: "/home/pi/photobooth-data/media/processed_full"
|
||||||
userdata_dir: "/home/pi/photobooth-data/userdata"
|
userdata_dir: "/home/pi/photobooth-data/userdata"
|
||||||
|
show_delete_button: false
|
||||||
|
|
||||||
|
# --- Evenement en cours ---
|
||||||
|
event:
|
||||||
|
name: "Evenement"
|
||||||
|
slug: "evenement"
|
||||||
|
started_at: 0
|
||||||
|
|
||||||
# --- Bouton physique ---
|
# --- Bouton physique ---
|
||||||
button:
|
button:
|
||||||
pin: 23 # GPIO BCM (input, pull-up interne)
|
pin: 23
|
||||||
relay_pin: 12 # GPIO BCM (output, relay ON/OFF bouton 12V)
|
relay_pin: 12
|
||||||
debounce_ms: 50
|
debounce_ms: 50
|
||||||
double_click_ms: 400 # Délai max entre clics pour multi-clic
|
double_click_ms: 400
|
||||||
long_press_ms: 1500 # Durée min long appui
|
long_press_ms: 1500
|
||||||
max_clicks: 4 # Nombre max de clics reconnus
|
max_clicks: 4
|
||||||
long_press_action: "print_last" # print_last | none
|
long_press_action: "print_last"
|
||||||
print_enabled: true
|
print_enabled: true
|
||||||
|
|
||||||
# --- Anneau LED WS2812b ---
|
# --- Anneau LED WS2812b ---
|
||||||
leds:
|
leds:
|
||||||
pin: 18 # GPIO BCM (PWM hardware, pin physique 12)
|
pin: 18
|
||||||
count: 35 # Nombre de LEDs dans l'anneau
|
count: 35
|
||||||
brightness: 180 # 0-255
|
brightness: 180 # Luminosite normale (0-255)
|
||||||
freq_hz: 800000
|
freq_hz: 800000
|
||||||
dma: 10
|
dma: 10
|
||||||
strip_type: "WS2812"
|
strip_type: "WS2812"
|
||||||
@@ -43,36 +50,51 @@ leds:
|
|||||||
color: [0, 30, 80]
|
color: [0, 30, 80]
|
||||||
mode: "breathe"
|
mode: "breathe"
|
||||||
speed: 0.025
|
speed: 0.025
|
||||||
|
|
||||||
countdown:
|
countdown:
|
||||||
color: [0, 200, 80]
|
color: [0, 200, 80]
|
||||||
mode: "fill_progressive"
|
mode: "fill_progressive"
|
||||||
|
|
||||||
capture:
|
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"
|
mode: "flash"
|
||||||
flashes: 2
|
flashes: 2
|
||||||
flash_duration: 0.1
|
# Duree en secondes de chaque flash (plus long = plus de lumiere)
|
||||||
|
flash_duration: 0.30
|
||||||
|
|
||||||
captured:
|
captured:
|
||||||
color: [150, 0, 200]
|
color: [150, 0, 200]
|
||||||
mode: "solid"
|
mode: "solid"
|
||||||
|
|
||||||
finished:
|
finished:
|
||||||
color: [0, 100, 255]
|
color: [0, 100, 255]
|
||||||
mode: "solid"
|
mode: "solid"
|
||||||
duration: 2.0
|
duration: 2.0
|
||||||
|
|
||||||
printing:
|
printing:
|
||||||
color: [0, 120, 255]
|
color: [0, 120, 255]
|
||||||
mode: "spin"
|
mode: "spin"
|
||||||
speed: 0.05
|
speed: 0.05
|
||||||
|
|
||||||
error:
|
error:
|
||||||
color: [220, 0, 0]
|
color: [220, 0, 0]
|
||||||
mode: "flash"
|
mode: "flash"
|
||||||
flashes: 4
|
flashes: 4
|
||||||
|
|
||||||
disabled:
|
disabled:
|
||||||
color: [60, 0, 0]
|
color: [60, 0, 0]
|
||||||
mode: "solid"
|
mode: "solid"
|
||||||
|
|
||||||
# --- Impression ---
|
# --- Impression ---
|
||||||
print:
|
print:
|
||||||
mode: "validation" # direct | gallery | validation
|
mode: "validation"
|
||||||
script_path: "/home/pi/photobooth-data/script/script_print.sh"
|
script_path: "/home/pi/photobooth-data/script/script_print.sh"
|
||||||
default_copies: 1
|
default_copies: 1
|
||||||
printers:
|
printers:
|
||||||
@@ -88,9 +110,8 @@ gallery:
|
|||||||
qr_base_url: "https://photomaton.lessapinsduweb.com"
|
qr_base_url: "https://photomaton.lessapinsduweb.com"
|
||||||
|
|
||||||
# --- Mapping clics bouton -> actions photobooth-app ---
|
# --- Mapping clics bouton -> actions photobooth-app ---
|
||||||
# n clics -> photobooth_index dans la liste actions.image de config.json
|
|
||||||
button_actions:
|
button_actions:
|
||||||
1: { label: "Photo normale", photobooth_index: 0 }
|
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 }
|
3: { label: "Photo cailloux", photobooth_index: 2 }
|
||||||
4: { label: "Photo soirée", photobooth_index: 3 }
|
4: { label: "Photo soiree", photobooth_index: 3 }
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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@<IP_DU_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://<IP>: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://<IP>: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://<IP>: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@<IP_DU_PI>:/home/pi/photobooth-data/userdata/
|
||||||
|
|
||||||
|
# Script d'impression
|
||||||
|
rsync -av /mnt/old-sd/home/pi/photobooth-data/script/ \
|
||||||
|
pi@<IP_DU_PI>:/home/pi/photobooth-data/script/
|
||||||
|
|
||||||
|
# Config photobooth-app (actions, frames configurées)
|
||||||
|
rsync -av /mnt/old-sd/home/pi/.config/photobooth-app/ \
|
||||||
|
pi@<IP_DU_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@<IP_DU_PI>:/home/pi/jh-photomaton/config/
|
||||||
|
rsync -av /mnt/old-sd/home/pi/jh-photomaton/data/ \
|
||||||
|
pi@<IP_DU_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://<IP>:8090/admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé des ports
|
||||||
|
|
||||||
|
| Service | Port | Accès |
|
||||||
|
|---------|------|-------|
|
||||||
|
| JH Photomaton (admin) | 8090 | `http://admin.lessapinsduweb.com` ou `http://<IP>:8090/admin` |
|
||||||
|
| photobooth-app (kiosque) | 8000 | `http://photomaton.lessapinsduweb.com` |
|
||||||
|
| CUPS (imprimantes) | 631 | `http://<IP>: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`.
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<a href="/admin/gallery">Galerie admin</a>
|
<a href="/admin/gallery">Galerie admin</a>
|
||||||
<a href="/admin/print">Impression</a>
|
<a href="/admin/print">Impression</a>
|
||||||
<a href="/admin/actions">Actions</a>
|
<a href="/admin/actions">Actions</a>
|
||||||
|
<a href="/admin/settings">Réglages</a>
|
||||||
<a href="/gallery">Galerie publique</a>
|
<a href="/gallery">Galerie publique</a>
|
||||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -53,6 +54,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Événement en cours -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="card-title">🎫 Événement en cours</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex gap-2 items-center mb-2" style="flex-wrap:wrap">
|
||||||
|
<input type="text" id="event-name-input" class="form-control"
|
||||||
|
placeholder="Nom de l'événement…" style="flex:1;min-width:180px">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveEvent(false)">💾 Enregistrer</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="saveEvent(true)"
|
||||||
|
title="Terminer cet événement et en démarrer un nouveau (les stats repartent de zéro)">
|
||||||
|
↻ Nouvel événement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-sm" id="event-stats-row">
|
||||||
|
<span>📸 <strong id="ev-photos">—</strong> photos</span>
|
||||||
|
<span>⬇️ <strong id="ev-downloads">—</strong> téléchargements</span>
|
||||||
|
<span>🖨 <strong id="ev-prints">—</strong> impressions</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-1" id="event-slug-display"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-2 section">
|
<div class="grid-2 section">
|
||||||
<!-- Contrôle LED -->
|
<!-- Contrôle LED -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
loadQueue();
|
loadQueue();
|
||||||
|
loadEvent();
|
||||||
setInterval(loadQueue, 15000);
|
setInterval(loadQueue, 15000);
|
||||||
|
setInterval(loadEvent, 60000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,45 +6,199 @@
|
|||||||
<a href="/admin/gallery" class="active">Galerie admin</a>
|
<a href="/admin/gallery" class="active">Galerie admin</a>
|
||||||
<a href="/admin/print">Impression</a>
|
<a href="/admin/print">Impression</a>
|
||||||
<a href="/admin/actions">Actions</a>
|
<a href="/admin/actions">Actions</a>
|
||||||
|
<a href="/admin/settings">Réglages</a>
|
||||||
<a href="/gallery">Galerie publique</a>
|
<a href="/gallery">Galerie publique</a>
|
||||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
/* ── Photo grid ───────────────────────────────────────────────────────────── */
|
||||||
|
.gallery-wrap { max-width: 1300px; margin: 0 auto; padding: 1rem; }
|
||||||
|
.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.gallery-title { font-size: 1.2rem; font-weight: 700; }
|
||||||
|
.gallery-controls { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||||
|
.gallery-filters { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; flex-wrap: wrap; }
|
||||||
|
.filter-btn { padding: .3rem .75rem; border-radius: 20px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); cursor: pointer; font-size: .85rem; transition: all .2s; }
|
||||||
|
.filter-btn.active, .filter-btn:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.filter-badge { background: rgba(224,123,0,.2); color: #e07b00; padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
|
||||||
|
|
||||||
|
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .75rem; }
|
||||||
|
.photo-card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 3/2;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color .2s, transform .15s;
|
||||||
|
}
|
||||||
|
.photo-card:hover { border-color: var(--primary); transform: scale(1.02); }
|
||||||
|
.photo-card.has-print-request { border-color: #e07b00; }
|
||||||
|
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
|
||||||
|
/* Badge demande d'impression */
|
||||||
|
.print-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px; right: 5px;
|
||||||
|
background: rgba(224,123,0,.9);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .15rem .45rem;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex; align-items: center; gap: .25rem;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.print-badge.printing { background: rgba(25,108,176,.9); }
|
||||||
|
|
||||||
|
/* Overlay actions au hover */
|
||||||
|
.card-overlay {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0,0,0,.55);
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
gap: .4rem; padding: .5rem;
|
||||||
|
opacity: 0; transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.photo-card:hover .card-overlay { opacity: 1; }
|
||||||
|
.ov-btn {
|
||||||
|
padding: .3rem .5rem; border: none; border-radius: 6px;
|
||||||
|
cursor: pointer; font-size: .8rem; font-weight: 700;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.ov-print { background: rgba(224,123,0,.85); color: #fff; }
|
||||||
|
.ov-cancel { background: rgba(192,0,0,.75); color: #fff; }
|
||||||
|
.ov-delete { background: rgba(80,80,80,.75); color: #fff; }
|
||||||
|
.ov-dl { background: rgba(40,40,40,.75); color: #ccc; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: 1rem 0; }
|
||||||
|
.pg-btn { padding: .4rem .9rem; border: 1px solid var(--border); background: var(--surface); border-radius: 6px; color: var(--text); cursor: pointer; }
|
||||||
|
.pg-btn:disabled { opacity: .35; cursor: default; }
|
||||||
|
.pg-info { color: var(--text-muted); font-size: .9rem; }
|
||||||
|
|
||||||
|
/* ── Lightbox ─────────────────────────────────────────────────────────────── */
|
||||||
|
.lightbox {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,.88); backdrop-filter: blur(6px);
|
||||||
|
flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 1rem; padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.lightbox.open { display: flex; }
|
||||||
|
.lightbox-img-wrap { position: relative; max-width: 80vw; max-height: 70vh; }
|
||||||
|
.lightbox-img-wrap img { max-width: 80vw; max-height: 70vh; border-radius: 8px; object-fit: contain; display: block; }
|
||||||
|
.lb-close { position: absolute; top: -14px; right: -14px; width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,.15); border: none; color: #fff; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.lb-info { text-align: center; }
|
||||||
|
.lb-id { font-size: .78rem; color: var(--text-muted); font-family: monospace; }
|
||||||
|
|
||||||
|
/* Bloc demandes en attente dans le lightbox */
|
||||||
|
.lb-print-status {
|
||||||
|
background: var(--surface); border-radius: 10px; padding: .85rem 1.25rem;
|
||||||
|
min-width: min(400px, 80vw); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.lb-print-title { font-size: .85rem; font-weight: 700; margin-bottom: .6rem; display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.lb-queue-entry {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: .3rem 0; border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
font-size: .82rem; gap: .75rem;
|
||||||
|
}
|
||||||
|
.lb-queue-status { padding: .15rem .5rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
|
||||||
|
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; }
|
||||||
|
.s-printing { background: rgba(25,108,176,.2); color: var(--primary); }
|
||||||
|
|
||||||
|
.lb-actions { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.lb-btn { padding: .5rem 1.1rem; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 600; transition: opacity .2s; }
|
||||||
|
.lb-btn:hover { opacity: .82; }
|
||||||
|
.lb-btn-print { background: var(--primary); color: #fff; }
|
||||||
|
.lb-btn-now { background: #1a7340; color: #fff; }
|
||||||
|
.lb-btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
|
||||||
|
.lb-btn-delete { background: #3d1f1f; color: #e05050; border: 1px solid rgba(192,0,0,.2); }
|
||||||
|
.lb-btn-dl { background: rgba(255,255,255,.08); color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
|
/* copies input */
|
||||||
|
.copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); }
|
||||||
|
.copies-wrap input { width: 52px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: .3rem .5rem; text-align: center; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="gallery-wrap">
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h1 class="page-title" style="margin:0">Galerie — Administration</h1>
|
<div class="gallery-header">
|
||||||
<div class="flex gap-1 items-center">
|
<div class="gallery-title">🖼 Galerie — Administration</div>
|
||||||
|
<div class="gallery-controls">
|
||||||
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
|
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
|
||||||
<div class="flex gap-1">
|
<div class="copies-wrap">
|
||||||
<input type="number" id="copies-input" min="1" max="3" value="1" class="form-control" style="width:70px" title="Copies">
|
<label for="copies-input">Copies :</label>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="refreshPhotos()">↻ Actualiser</button>
|
<input type="number" id="copies-input" min="1" max="3" value="1">
|
||||||
</div>
|
</div>
|
||||||
|
<button class="pg-btn" onclick="refreshPhotos()">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres / navigation pages -->
|
<!-- Filtres -->
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="gallery-filters">
|
||||||
<button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
|
<button class="filter-btn active" onclick="setFilter('all', this)">Toutes</button>
|
||||||
<span class="text-sm text-muted">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
|
<button class="filter-btn" onclick="setFilter('pending', this)">
|
||||||
<button class="btn btn-ghost btn-sm" id="next-btn" onclick="changePage(1)">Suiv. →</button>
|
🖨 À imprimer <span class="filter-badge" id="pending-count">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="pg-btn" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
|
||||||
|
<span class="pg-info">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
|
||||||
|
<button class="pg-btn" id="next-btn" onclick="changePage(1)">Suiv. →</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="photo-grid" id="photo-grid">
|
<div class="photo-grid" id="photo-grid">
|
||||||
<div class="empty-state"><div class="icon">⏳</div>Chargement…</div>
|
<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightbox admin -->
|
<div class="pagination" style="margin-top:.5rem">
|
||||||
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
|
<button class="pg-btn" id="prev-btn2" onclick="changePage(-1)" disabled>← Préc.</button>
|
||||||
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
<span class="pg-info">Page <span id="page-cur2">1</span> / <span id="page-total2">1</span></span>
|
||||||
<img id="lightbox-img" src="" alt="">
|
<button class="pg-btn" id="next-btn2" onclick="changePage(1)">Suiv. →</button>
|
||||||
<div class="lightbox-actions">
|
|
||||||
<button class="btn btn-success" id="lb-print-btn">🖨 Imprimer</button>
|
|
||||||
<a class="btn btn-ghost" id="lb-download-btn" download>⬇ Télécharger</a>
|
|
||||||
<button class="btn btn-danger" id="lb-delete-btn">🗑 Supprimer</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted" id="lb-filename"></div>
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
|
||||||
|
<div class="lightbox-img-wrap">
|
||||||
|
<img id="lb-img" src="" alt="">
|
||||||
|
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lb-info">
|
||||||
|
<div class="lb-id" id="lb-id"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bloc demandes d'impression en attente -->
|
||||||
|
<div class="lb-print-status" id="lb-print-status" style="display:none">
|
||||||
|
<div class="lb-print-title">🖨 Demandes d'impression en attente</div>
|
||||||
|
<div id="lb-queue-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons d'action -->
|
||||||
|
<div class="lb-actions">
|
||||||
|
<button class="lb-btn lb-btn-print" id="lb-btn-queue" onclick="lbAddToQueue()">
|
||||||
|
📋 Ajouter à la file
|
||||||
|
</button>
|
||||||
|
<button class="lb-btn lb-btn-now" id="lb-btn-now" onclick="lbPrintNow()">
|
||||||
|
🖨 Imprimer maintenant
|
||||||
|
</button>
|
||||||
|
<button class="lb-btn lb-btn-cancel" id="lb-btn-cancel-all" onclick="lbCancelAll()" style="display:none">
|
||||||
|
✕ Annuler la demande
|
||||||
|
</button>
|
||||||
|
<a class="lb-btn lb-btn-dl" id="lb-btn-dl" download>⬇ Télécharger</a>
|
||||||
|
<button class="lb-btn lb-btn-delete" onclick="lbDelete()">🗑 Supprimer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -53,99 +207,311 @@
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let currentPhotoId = null;
|
let currentPhotoId = null;
|
||||||
|
let currentPhotoData = null;
|
||||||
|
let allPhotos = [];
|
||||||
|
let filterMode = 'all';
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Chargement photos
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
async function loadPhotos(page = 1) {
|
async function loadPhotos(page = 1) {
|
||||||
|
const grid = document.getElementById('photo-grid');
|
||||||
|
grid.innerHTML = '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
|
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
|
||||||
const { photos, total, pages } = data;
|
allPhotos = data.photos || [];
|
||||||
|
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
totalPages = pages;
|
totalPages = data.pages || 1;
|
||||||
|
|
||||||
document.getElementById('photo-count').textContent = `${total} photo(s)`;
|
updatePagination();
|
||||||
document.getElementById('page-cur').textContent = page;
|
document.getElementById('photo-count').textContent = `${data.total} photo(s)`;
|
||||||
document.getElementById('page-total').textContent = pages;
|
|
||||||
document.getElementById('prev-btn').disabled = page <= 1;
|
|
||||||
document.getElementById('next-btn').disabled = page >= pages;
|
|
||||||
|
|
||||||
|
// Compteur de demandes en attente
|
||||||
|
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
|
||||||
|
document.getElementById('pending-count').textContent = pendingTotal;
|
||||||
|
|
||||||
|
renderGrid();
|
||||||
|
} catch(e) {
|
||||||
|
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
const grid = document.getElementById('photo-grid');
|
const grid = document.getElementById('photo-grid');
|
||||||
|
let photos = allPhotos;
|
||||||
|
|
||||||
|
if (filterMode === 'pending') {
|
||||||
|
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (!photos.length) {
|
if (!photos.length) {
|
||||||
grid.innerHTML = '<div class="empty-state"><div class="icon">📷</div>Aucune photo</div>';
|
grid.innerHTML = filterMode === 'pending'
|
||||||
|
? '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">✅ Aucune demande d\'impression en attente</div>'
|
||||||
|
: '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">📷 Aucune photo</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = photos.map(p => {
|
grid.innerHTML = photos.map(p => {
|
||||||
const pid = p.id || p.filename || p.uid || '';
|
const pid = p.photo_id || p.id || p.filename || '';
|
||||||
|
const hasPending = p.print_pending > 0;
|
||||||
|
const hasPrinting = p.print_printing > 0;
|
||||||
|
|
||||||
|
let badge = '';
|
||||||
|
if (hasPrinting) badge = `<div class="print-badge printing">🔵 Impression…</div>`;
|
||||||
|
else if (hasPending) badge = `<div class="print-badge">🖨 ${p.print_pending} en attente</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="photo-card" onclick="openLightbox('${pid}', '${p.full_url}', '${p.thumb_url}')">
|
<div class="photo-card ${hasPending || hasPrinting ? 'has-print-request' : ''}"
|
||||||
|
id="card-${pid}"
|
||||||
|
onclick="openLightbox(${JSON.stringify(p)})">
|
||||||
<img src="${p.thumb_url}" loading="lazy" alt="">
|
<img src="${p.thumb_url}" loading="lazy" alt="">
|
||||||
<div class="photo-card-actions">
|
${badge}
|
||||||
<button class="btn btn-success btn-sm" onclick="event.stopPropagation();printPhoto('${pid}')">🖨</button>
|
<div class="card-overlay">
|
||||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
|
${hasPending
|
||||||
<a class="btn btn-ghost btn-sm" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
|
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕ Annuler</button>`
|
||||||
|
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨 File</button>`
|
||||||
|
}
|
||||||
|
<button class="ov-btn ov-delete" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
|
||||||
|
<a class="ov-btn ov-dl" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('photo-grid').innerHTML = '<div class="empty-state"><div class="icon">❌</div>Erreur de chargement</div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(delta) {
|
function setFilter(mode, btn) {
|
||||||
loadPhotos(currentPage + delta);
|
filterMode = mode;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshPhotos() { loadPhotos(currentPage); }
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Lightbox
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function openLightbox(photo) {
|
||||||
|
currentPhotoData = photo;
|
||||||
|
currentPhotoId = photo.photo_id || photo.id || photo.filename || '';
|
||||||
|
|
||||||
|
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url;
|
||||||
|
document.getElementById('lb-id').textContent = currentPhotoId;
|
||||||
|
document.getElementById('lb-btn-dl').href = photo.full_url;
|
||||||
|
|
||||||
|
// Mise à jour du bloc demandes
|
||||||
|
updateLightboxPrintStatus(photo);
|
||||||
|
|
||||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
|
||||||
function openLightbox(pid, fullUrl, thumbUrl) {
|
|
||||||
currentPhotoId = pid;
|
|
||||||
document.getElementById('lightbox-img').src = fullUrl || thumbUrl;
|
|
||||||
document.getElementById('lb-download-btn').href = fullUrl;
|
|
||||||
document.getElementById('lb-filename').textContent = pid;
|
|
||||||
document.getElementById('lb-print-btn').onclick = () => printPhoto(pid);
|
|
||||||
document.getElementById('lb-delete-btn').onclick = () => deletePhoto(pid);
|
|
||||||
document.getElementById('lightbox').classList.add('open');
|
document.getElementById('lightbox').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLightbox(e) {
|
function updateLightboxPrintStatus(photo) {
|
||||||
if (e && e.target !== document.getElementById('lightbox') && e.type !== 'click') return;
|
const statusBlock = document.getElementById('lb-print-status');
|
||||||
document.getElementById('lightbox').classList.remove('open');
|
const queueList = document.getElementById('lb-queue-list');
|
||||||
currentPhotoId = null;
|
const btnCancelAll = document.getElementById('lb-btn-cancel-all');
|
||||||
|
|
||||||
|
const requests = photo.print_requests || [];
|
||||||
|
const activeRequests = requests.filter(r => r.status === 'pending' || r.status === 'printing');
|
||||||
|
|
||||||
|
if (activeRequests.length) {
|
||||||
|
statusBlock.style.display = 'block';
|
||||||
|
btnCancelAll.style.display = 'inline-flex';
|
||||||
|
queueList.innerHTML = activeRequests.map(r => `
|
||||||
|
<div class="lb-queue-entry">
|
||||||
|
<span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span>
|
||||||
|
<span class="lb-queue-status ${r.status === 'printing' ? 's-printing' : 's-pending'}">
|
||||||
|
${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:.75rem;color:var(--text-muted)">${new Date(r.requested_at * 1000).toLocaleTimeString('fr-FR')}</span>
|
||||||
|
${r.status === 'pending'
|
||||||
|
? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem"
|
||||||
|
onclick="cancelOneEntry('${r.id}')">✕</button>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
statusBlock.style.display = 'none';
|
||||||
|
btnCancelAll.style.display = 'none';
|
||||||
|
queueList.innerHTML = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
document.getElementById('lightbox').classList.remove('open');
|
||||||
|
currentPhotoId = null;
|
||||||
|
currentPhotoData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lightbox').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('lightbox')) closeLightbox();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
async function printPhoto(pid) {
|
// Actions impression
|
||||||
const copies = parseInt(document.getElementById('copies-input').value) || 1;
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
if (!confirm(`Imprimer ${copies} copie(s) ?`)) return;
|
function getCopies() {
|
||||||
|
return parseInt(document.getElementById('copies-input').value) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lbAddToQueue() {
|
||||||
|
const copies = getCopies();
|
||||||
try {
|
try {
|
||||||
const r = await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}`);
|
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
|
||||||
if (r.success) showToast('✅ Impression lancée sur ' + r.printer, 'success');
|
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info');
|
||||||
else if (r.ok) showToast('📋 Demande ajoutée à la file', 'info');
|
// Mettre à jour la photo locale et le lightbox
|
||||||
|
await refreshPhotoData(currentPhotoId);
|
||||||
|
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lbPrintNow() {
|
||||||
|
const copies = getCopies();
|
||||||
|
if (!confirm(`Imprimer ${copies} copie(s) immédiatement ?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`);
|
||||||
|
if (r.success) showToast(`✅ Imprimé sur ${r.printer}`, 'success');
|
||||||
else showToast('❌ ' + (r.error || 'Erreur'), 'error');
|
else showToast('❌ ' + (r.error || 'Erreur'), 'error');
|
||||||
} catch(e) { showToast('Erreur impression', 'error'); }
|
await refreshPhotoData(currentPhotoId);
|
||||||
|
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lbCancelAll() {
|
||||||
|
try {
|
||||||
|
const r = await api('DELETE', `/admin/api/gallery/print/${currentPhotoId}`);
|
||||||
|
showToast(`✕ ${r.cancelled} demande(s) annulée(s)`, 'info');
|
||||||
|
await refreshPhotoData(currentPhotoId);
|
||||||
|
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelOneEntry(entryId) {
|
||||||
|
try {
|
||||||
|
await api('POST', `/api/print/cancel/${entryId}`);
|
||||||
|
showToast('Demande annulée', 'info');
|
||||||
|
await refreshPhotoData(currentPhotoId);
|
||||||
|
} catch(e) { showToast('Erreur', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lbDelete() {
|
||||||
|
if (!confirm('Supprimer cette photo définitivement ?')) return;
|
||||||
|
try {
|
||||||
|
const r = await api('DELETE', `/admin/api/gallery/${currentPhotoId}`);
|
||||||
|
if (r.ok) {
|
||||||
|
showToast('🗑 Photo supprimée', 'success');
|
||||||
|
closeLightbox();
|
||||||
|
loadPhotos(currentPage);
|
||||||
|
} else showToast('❌ Erreur suppression', 'error');
|
||||||
|
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions depuis la grille (sans ouvrir le lightbox)
|
||||||
|
async function quickQueue(pid) {
|
||||||
|
const copies = getCopies();
|
||||||
|
try {
|
||||||
|
await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}&immediate=false`);
|
||||||
|
showToast('📋 Ajouté à la file', 'info');
|
||||||
|
await refreshPhotoData(pid);
|
||||||
|
} catch(e) { showToast('Erreur', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickCancel(pid) {
|
||||||
|
try {
|
||||||
|
const r = await api('DELETE', `/admin/api/gallery/print/${pid}`);
|
||||||
|
showToast(`✕ ${r.cancelled} demande(s) annulée(s)`, 'info');
|
||||||
|
await refreshPhotoData(pid);
|
||||||
|
} catch(e) { showToast('Erreur', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePhoto(pid) {
|
async function deletePhoto(pid) {
|
||||||
if (!confirm('Supprimer cette photo définitivement ?')) return;
|
if (!confirm('Supprimer cette photo ?')) return;
|
||||||
try {
|
try {
|
||||||
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
|
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
|
||||||
showToast(r.ok ? '🗑 Photo supprimée' : '❌ Erreur suppression', r.ok ? 'success' : 'error');
|
if (r.ok) { showToast('🗑 Supprimée', 'success'); loadPhotos(currentPage); }
|
||||||
if (r.ok) {
|
} catch(e) { showToast('Erreur', 'error'); }
|
||||||
document.getElementById('lightbox').classList.remove('open');
|
|
||||||
loadPhotos(currentPage);
|
|
||||||
}
|
|
||||||
} catch(e) { showToast('Erreur suppression', 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWsMessage(msg) {
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
if (msg.type === 'photo_deleted') loadPhotos(currentPage);
|
// Helpers
|
||||||
if (msg.type === 'print_result') showToast(msg.result.success ? '✅ Impression OK' : '❌ ' + msg.result.error, msg.result.success ? 'success' : 'error');
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function refreshPhotoData(pid) {
|
||||||
|
// Re-fetch le statut des demandes pour cette photo et met à jour l'UI
|
||||||
|
try {
|
||||||
|
const status = await api('GET', '/admin/api/gallery/print-status');
|
||||||
|
const count = status[pid] || 0;
|
||||||
|
|
||||||
|
// Met à jour dans allPhotos
|
||||||
|
const idx = allPhotos.findIndex(p => (p.photo_id || p.id) === pid);
|
||||||
|
if (idx >= 0) {
|
||||||
|
allPhotos[idx].print_pending = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si le lightbox est ouvert pour cette photo, re-fetch les détails
|
||||||
|
if (currentPhotoId === pid) {
|
||||||
|
const data = await api('GET', `/admin/api/gallery/photos?page=${currentPage}&limit=24`);
|
||||||
|
const photo = (data.photos || []).find(p => (p.photo_id || p.id) === pid);
|
||||||
|
if (photo) {
|
||||||
|
currentPhotoData = photo;
|
||||||
|
updateLightboxPrintStatus(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render la grille (met à jour le badge)
|
||||||
|
renderGrid();
|
||||||
|
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
|
||||||
|
document.getElementById('pending-count').textContent = pendingTotal;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination() {
|
||||||
|
for (const suffix of ['', '2']) {
|
||||||
|
const cur = document.getElementById(`page-cur${suffix}`);
|
||||||
|
const tot = document.getElementById(`page-total${suffix}`);
|
||||||
|
const prev = document.getElementById(`prev-btn${suffix}`);
|
||||||
|
const next = document.getElementById(`next-btn${suffix}`);
|
||||||
|
if (cur) cur.textContent = currentPage;
|
||||||
|
if (tot) tot.textContent = totalPages;
|
||||||
|
if (prev) prev.disabled = currentPage <= 1;
|
||||||
|
if (next) next.disabled = currentPage >= totalPages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(delta) { loadPhotos(currentPage + delta); }
|
||||||
|
function refreshPhotos() { loadPhotos(currentPage); }
|
||||||
|
|
||||||
|
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||||
|
function onWsMessage(msg) {
|
||||||
|
if (msg.type === 'photo_deleted') {
|
||||||
|
loadPhotos(currentPage);
|
||||||
|
}
|
||||||
|
if (msg.type === 'print_request' && msg.photo_id) {
|
||||||
|
refreshPhotoData(msg.photo_id);
|
||||||
|
showToast(`🖨 Demande d'impression reçue`, 'info');
|
||||||
|
}
|
||||||
|
if (msg.type === 'print_result') {
|
||||||
|
const ok = msg.result && msg.result.success;
|
||||||
|
showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `❌ ${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error');
|
||||||
|
if (msg.photo_id) refreshPhotoData(msg.photo_id);
|
||||||
|
}
|
||||||
|
if (msg.type === 'print_cancelled_for_photo' && msg.photo_id) {
|
||||||
|
refreshPhotoData(msg.photo_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh des badges toutes les 20s
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await api('GET', '/admin/api/gallery/print-status');
|
||||||
|
let changed = false;
|
||||||
|
allPhotos.forEach(p => {
|
||||||
|
const pid = p.photo_id || p.id;
|
||||||
|
const newCount = status[pid] || 0;
|
||||||
|
if (p.print_pending !== newCount) {
|
||||||
|
p.print_pending = newCount;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) renderGrid();
|
||||||
|
} catch(e) {}
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
loadPhotos(1);
|
loadPhotos(1);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,106 +6,270 @@
|
|||||||
<a href="/admin/gallery">Galerie admin</a>
|
<a href="/admin/gallery">Galerie admin</a>
|
||||||
<a href="/admin/print" class="active">Impression</a>
|
<a href="/admin/print" class="active">Impression</a>
|
||||||
<a href="/admin/actions">Actions</a>
|
<a href="/admin/actions">Actions</a>
|
||||||
|
<a href="/admin/settings">Réglages</a>
|
||||||
<a href="/gallery">Galerie publique</a>
|
<a href="/gallery">Galerie publique</a>
|
||||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.print-grid { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Imprimantes ──────────────────────────────────────────────────────────── */
|
||||||
|
.printers-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||||
|
.printer-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .85rem;
|
||||||
|
}
|
||||||
|
.printer-card.state-disabled { border-color: rgba(224,123,0,.4); }
|
||||||
|
.printer-card.state-offline { border-color: rgba(192,0,0,.3); opacity: .7; }
|
||||||
|
.printer-card.state-printing { border-color: var(--primary); }
|
||||||
|
|
||||||
|
.printer-head { display: flex; align-items: center; gap: .75rem; }
|
||||||
|
.printer-icon { font-size: 2rem; }
|
||||||
|
.printer-name { font-weight: 700; font-size: 1.05rem; }
|
||||||
|
.printer-sublabel { font-size: .78rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.printer-states { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.badge-printer {
|
||||||
|
display: inline-flex; align-items: center; gap: .3rem;
|
||||||
|
padding: .25rem .65rem; border-radius: 20px; font-size: .8rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.bs-idle { background: rgba(26,115,64,.2); color: #4caf50; }
|
||||||
|
.bs-printing { background: rgba(25,108,176,.2); color: var(--primary); }
|
||||||
|
.bs-disabled { background: rgba(224,123,0,.2); color: #e07b00; }
|
||||||
|
.bs-offline { background: rgba(192,0,0,.2); color: #e05050; }
|
||||||
|
.bs-unknown { background: rgba(128,128,128,.2);color: #aaa; }
|
||||||
|
.bs-accept { background: rgba(26,115,64,.15); color: #66bb6a; border: 1px solid rgba(26,115,64,.3); }
|
||||||
|
.bs-reject { background: rgba(192,0,0,.12); color: #ef5350; border: 1px solid rgba(192,0,0,.2); }
|
||||||
|
|
||||||
|
.printer-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.btn-xs { padding: .3rem .65rem; font-size: .8rem; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: opacity .2s; }
|
||||||
|
.btn-xs:hover { opacity: .8; }
|
||||||
|
.btn-enable { background: #1a7340; color: #fff; }
|
||||||
|
.btn-disable { background: #784700; color: #fff; }
|
||||||
|
.btn-clear { background: #7a0000; color: #fff; }
|
||||||
|
.btn-reject { background: #555; color: #fff; }
|
||||||
|
|
||||||
|
/* Jobs CUPS détaillés */
|
||||||
|
.cups-jobs { font-size: .82rem; }
|
||||||
|
.cups-job-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.05);
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.cups-job-id { font-family: monospace; color: var(--primary); }
|
||||||
|
.cups-job-info { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cups-empty { color: var(--text-muted); font-size: .82rem; font-style: italic; padding: .4rem 0; }
|
||||||
|
|
||||||
|
/* ── Mode d'impression ────────────────────────────────────────────────────── */
|
||||||
|
.mode-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; }
|
||||||
|
.mode-options { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: .75rem; }
|
||||||
|
.mode-option { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
|
||||||
|
.mode-option input[type=radio] { accent-color: var(--primary); width: 1rem; height: 1rem; }
|
||||||
|
.mode-option label { cursor: pointer; font-size: .95rem; }
|
||||||
|
.mode-hint { font-size: .8rem; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── File d'attente ───────────────────────────────────────────────────────── */
|
||||||
|
.queue-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.queue-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.queue-title { font-size: 1rem; font-weight: 700; color: var(--text); }
|
||||||
|
.queue-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.queue-table th { padding: .6rem 1rem; text-align: left; font-size: .8rem; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); }
|
||||||
|
.queue-table td { padding: .65rem 1rem; border-bottom: 1px solid rgba(255,255,255,.04); font-size: .9rem; vertical-align: middle; }
|
||||||
|
.print-thumb { width: 48px; height: 32px; object-fit: cover; border-radius: 4px; background: var(--bg); }
|
||||||
|
.copies-input { width: 50px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 2px 6px; text-align: center; }
|
||||||
|
.queue-empty { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .95rem; }
|
||||||
|
|
||||||
|
/* ── Badges ───────────────────────────────────────────────────────────────── */
|
||||||
|
.badge { display: inline-flex; align-items: center; padding: .2rem .55rem; border-radius: 20px; font-size: .78rem; font-weight: 700; }
|
||||||
|
.badge-success { background: rgba(26,115,64,.2); color: #4caf50; }
|
||||||
|
.badge-info { background: rgba(25,108,176,.2); color: var(--primary); }
|
||||||
|
.badge-warning { background: rgba(224,123,0,.2); color: #e07b00; }
|
||||||
|
.badge-error { background: rgba(192,0,0,.2); color: #e05050; }
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.section-title { font-size: 1rem; font-weight: 700; color: var(--text); margin-bottom: .75rem; display: flex; align-items: center; gap: .4rem; }
|
||||||
|
|
||||||
|
/* Inline buttons */
|
||||||
|
.btn-sm { padding: .35rem .75rem; border: none; border-radius: 6px; cursor: pointer; font-size: .85rem; font-weight: 600; transition: opacity .2s; }
|
||||||
|
.btn-sm:hover { opacity: .8; }
|
||||||
|
.btn-print { background: var(--primary); color: #fff; }
|
||||||
|
.btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
|
||||||
|
.btn-refresh { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="print-grid">
|
||||||
<h1 class="page-title">Gestion de l'impression</h1>
|
|
||||||
|
|
||||||
<!-- Imprimantes -->
|
<!-- ── IMPRIMANTES ─────────────────────────────────────────────────────── -->
|
||||||
<div class="section">
|
<div>
|
||||||
<div class="card-title">Imprimantes CUPS</div>
|
<div class="section-title">🖨 Imprimantes CUPS</div>
|
||||||
<div class="card">
|
<div class="printers-row" id="printers-container">
|
||||||
<table class="table" id="printers-table">
|
|
||||||
<thead><tr><th>Imprimante</th><th>Statut</th><th>Jobs en attente</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for p in printers %}
|
{% for p in printers %}
|
||||||
<tr>
|
<div class="printer-card state-{{ p.state }}" id="pc-{{ p.name }}">
|
||||||
<td class="font-bold">{{ p.label }}</td>
|
|
||||||
<td>
|
<div class="printer-head">
|
||||||
<span class="badge
|
<span class="printer-icon">
|
||||||
{% if p.state == 'idle' %}badge-success
|
{% if p.state == 'printing' %}🔵{% elif p.state == 'idle' %}🟢{% elif p.state == 'disabled' %}🟠{% else %}🔴{% endif %}
|
||||||
{% elif p.state == 'printing' %}badge-info
|
|
||||||
{% elif p.state == 'disabled' %}badge-warning
|
|
||||||
{% else %}badge-error{% endif %}
|
|
||||||
">
|
|
||||||
{% if p.state == 'idle' %}Disponible
|
|
||||||
{% elif p.state == 'printing' %}Impression
|
|
||||||
{% elif p.state == 'disabled' %}Désactivée
|
|
||||||
{% elif p.state == 'offline' %}Hors ligne
|
|
||||||
{% else %}{{ p.state }}{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<div>
|
||||||
<td>{{ p.jobs }}</td>
|
<div class="printer-name">{{ p.label or p.name }}</div>
|
||||||
<td>
|
<div class="printer-sublabel">{{ p.name }}</div>
|
||||||
<button class="btn btn-danger btn-sm" onclick="cancelCupsJobs('{{ p.name }}')">✕ Vider file</button>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
|
<div class="printer-states">
|
||||||
|
<span class="badge-printer bs-{{ p.state }}">
|
||||||
|
{% if p.state == 'idle' %}✅ Disponible
|
||||||
|
{% elif p.state == 'printing' %}🖨 En impression
|
||||||
|
{% elif p.state == 'disabled' %}⏸ Désactivée
|
||||||
|
{% elif p.state == 'offline' %}❌ Hors ligne
|
||||||
|
{% else %}❓ {{ p.state }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="badge-printer {{ 'bs-accept' if p.accepting else 'bs-reject' }}">
|
||||||
|
{{ '✓ Accepte les jobs' if p.accepting else '✗ Refuse les jobs' }}
|
||||||
|
</span>
|
||||||
|
{% if p.jobs_count %}
|
||||||
|
<span class="badge-printer bs-printing">{{ p.jobs_count }} job{{ 's' if p.jobs_count > 1 else '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions imprimante -->
|
||||||
|
<div class="printer-actions">
|
||||||
|
{% if p.state == 'disabled' %}
|
||||||
|
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">▶ Activer</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn-xs btn-disable" onclick="printerAction('{{ p.name }}','disable')">⏸ Désactiver</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.accepting %}
|
||||||
|
<button class="btn-xs btn-reject" onclick="printerAction('{{ p.name }}','reject')">🚫 Refuser jobs</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">✓ Accepter jobs</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.jobs_count %}
|
||||||
|
<button class="btn-xs btn-clear" onclick="clearJobs('{{ p.name }}')">✕ Vider file</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs CUPS en cours -->
|
||||||
|
<div class="cups-jobs" id="jobs-{{ p.name }}">
|
||||||
|
{% if p.jobs %}
|
||||||
|
{% for j in p.jobs %}
|
||||||
|
<div class="cups-job-row">
|
||||||
|
<span class="cups-job-id">{{ j.id }}</span>
|
||||||
|
<span class="cups-job-info">{{ j.user }} — {{ j.size }}</span>
|
||||||
|
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('{{ j.id }}')">✕</button>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
{% else %}
|
||||||
</table>
|
<div class="cups-empty">Aucun job en cours</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.5rem">
|
||||||
|
<button class="btn-sm btn-refresh" onclick="refreshPrinters()">↻ Actualiser imprimantes</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode impression -->
|
<!-- ── MODE D'IMPRESSION ──────────────────────────────────────────────── -->
|
||||||
<div class="section">
|
<div>
|
||||||
<div class="card-title">Mode d'impression</div>
|
<div class="section-title">⚙️ Mode d'impression</div>
|
||||||
<div class="card">
|
<div class="mode-card">
|
||||||
<div class="flex gap-1 mb-1">
|
<div class="mode-options">
|
||||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
<label class="mode-option">
|
||||||
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}> Direct (impression automatique)
|
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}>
|
||||||
|
<label>🚀 Direct (auto)</label>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
<label class="mode-option">
|
||||||
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}> Validation (admin confirme)
|
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}>
|
||||||
|
<label>✋ Validation admin</label>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
<label class="mode-option">
|
||||||
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}> Galerie (file uniquement)
|
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}>
|
||||||
|
<label>🖼 File galerie</label>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">
|
<div class="mode-hint">
|
||||||
<b>Direct</b> : impression lancée immédiatement sans confirmation. <b>Validation</b> : l'admin valide chaque impression. <b>Galerie</b> : les demandes s'accumulent, impression via la galerie admin.
|
<b>Direct</b> : chaque demande est imprimée immédiatement (load-balancing automatique entre les Selphy).
|
||||||
</p>
|
<b>Validation</b> : l'admin doit valider chaque impression via cette page.
|
||||||
|
<b>File galerie</b> : les demandes s'accumulent sans impression automatique.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File d'attente -->
|
<!-- ── FILE D'ATTENTE ─────────────────────────────────────────────────── -->
|
||||||
<div class="section">
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="queue-card">
|
||||||
<div class="card-title" style="margin:0">File d'attente (<span id="pending-count">{{ queue|length }}</span>)</div>
|
<div class="queue-header">
|
||||||
<button class="btn btn-ghost btn-sm" onclick="refreshQueue()">↻ Actualiser</button>
|
<div class="queue-title">📋 File d'attente (<span id="pending-count">{{ queue|selectattr('status','equalto','pending')|list|length }}</span> en attente)</div>
|
||||||
|
<button class="btn-sm btn-refresh" onclick="refreshQueue()">↻ Actualiser</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div id="queue-container">
|
<div id="queue-container">
|
||||||
{% if not queue %}
|
{% if not queue %}
|
||||||
<div class="empty-state"><div class="icon">✅</div>Aucune impression en attente</div>
|
<div class="queue-empty">✅ Aucune impression enregistrée</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<table class="table" id="queue-table">
|
<table class="queue-table">
|
||||||
<thead><tr><th>Aperçu</th><th>Fichier</th><th>Copies</th><th>Statut</th><th>Demandé</th><th>Actions</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th>Aperçu</th>
|
||||||
|
<th>Fichier</th>
|
||||||
|
<th>Copies</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Demandé le</th>
|
||||||
|
<th>Imprimante</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
<tbody id="queue-tbody">
|
<tbody id="queue-tbody">
|
||||||
{% for q in queue %}
|
{% for q in queue %}
|
||||||
<tr id="row-{{ q.id }}">
|
<tr id="row-{{ q.id }}">
|
||||||
<td><img src="{{ q.thumb_url }}" class="print-thumb" onerror="this.style.opacity=0"></td>
|
<td><img src="{{ q.thumb_url }}" class="print-thumb" onerror="this.style.opacity=0"></td>
|
||||||
<td class="text-sm">{{ q.filename.split('/')[-1] }}</td>
|
<td style="font-size:.82rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
|
{{ q.filename.split('/')[-1] }}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" value="{{ q.copies }}" min="1" max="3" id="copies-{{ q.id }}" style="width:50px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:4px;padding:2px 6px">
|
{% if q.status == 'pending' %}
|
||||||
|
<input type="number" value="{{ q.copies }}" min="1" max="3"
|
||||||
|
id="copies-{{ q.id }}" class="copies-input">
|
||||||
|
{% else %}
|
||||||
|
{{ q.copies }}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge
|
<span class="badge
|
||||||
{% if q.status == 'pending' %}badge-warning
|
{% if q.status == 'pending' %}badge-warning
|
||||||
{% elif q.status == 'done' %}badge-success
|
{% elif q.status == 'done' %}badge-success
|
||||||
{% elif q.status == 'printing' %}badge-info
|
{% elif q.status == 'printing' %}badge-info
|
||||||
{% else %}badge-error{% endif %}
|
{% elif q.status == 'cancelled' %}badge-error
|
||||||
">{{ q.status }}</span>
|
{% else %}badge-error{% endif %}">
|
||||||
|
{% if q.status == 'pending' %}En attente
|
||||||
|
{% elif q.status == 'done' %}Imprimé
|
||||||
|
{% elif q.status == 'printing' %}En cours
|
||||||
|
{% elif q.status == 'cancelled' %}Annulé
|
||||||
|
{% else %}Erreur{% endif %}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs text-muted">{{ q.requested_at|int }}</td>
|
<td style="font-size:.78rem;color:var(--text-muted)">
|
||||||
<td>
|
{{ q.requested_at|int|timestamp_to_date if q.requested_at else '—' }}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
|
||||||
|
<td style="display:flex;gap:.4rem;align-items:center">
|
||||||
{% if q.status == 'pending' %}
|
{% if q.status == 'pending' %}
|
||||||
<button class="btn btn-success btn-sm" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
|
<button class="btn-sm btn-print" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="cancelPrint('{{ q.id }}')">✕</button>
|
<button class="btn-sm btn-cancel" onclick="cancelPrint('{{ q.id }}')">✕</button>
|
||||||
{% elif q.status == 'done' %}
|
{% elif q.status == 'error' %}
|
||||||
<span class="text-sm text-muted">{{ q.printer or '—' }}</span>
|
<span style="font-size:.75rem;color:#e05050">{{ q.error_msg or 'Erreur' }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -116,6 +280,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -126,31 +291,83 @@ document.querySelectorAll('input[name="print-mode"]').forEach(r => {
|
|||||||
r.addEventListener('change', async (e) => {
|
r.addEventListener('change', async (e) => {
|
||||||
try {
|
try {
|
||||||
await api('POST', `/api/print/mode?mode=${e.target.value}`);
|
await api('POST', `/api/print/mode?mode=${e.target.value}`);
|
||||||
showToast(`Mode: ${e.target.value}`, 'success');
|
showToast(`Mode d'impression : ${e.target.value}`, 'success');
|
||||||
} catch(e) { showToast('Erreur', 'error'); }
|
} catch(err) { showToast('Erreur', 'error'); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Imprimantes ────────────────────────────────────────────────────────────────
|
||||||
|
async function refreshPrinters() {
|
||||||
|
try {
|
||||||
|
const printers = await api('GET', '/api/print/printers');
|
||||||
|
printers.forEach(p => {
|
||||||
|
// Met à jour les badges et compteur de jobs
|
||||||
|
const card = document.getElementById('pc-' + p.name);
|
||||||
|
if (!card) return;
|
||||||
|
card.className = `printer-card state-${p.state}`;
|
||||||
|
// Actualise les jobs CUPS
|
||||||
|
const jobsDiv = document.getElementById('jobs-' + p.name);
|
||||||
|
if (jobsDiv) {
|
||||||
|
if (p.jobs && p.jobs.length) {
|
||||||
|
jobsDiv.innerHTML = p.jobs.map(j => `
|
||||||
|
<div class="cups-job-row">
|
||||||
|
<span class="cups-job-id">${j.id}</span>
|
||||||
|
<span class="cups-job-info">${j.user} — ${j.size}</span>
|
||||||
|
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('${j.id}')">✕</button>
|
||||||
|
</div>`).join('');
|
||||||
|
} else {
|
||||||
|
jobsDiv.innerHTML = '<div class="cups-empty">Aucun job en cours</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printerAction(name, action) {
|
||||||
|
try {
|
||||||
|
await api('POST', `/api/print/printers/${name}/${action}`);
|
||||||
|
showToast(`${name} : ${action}`, 'success');
|
||||||
|
setTimeout(refreshPrinters, 800);
|
||||||
|
} catch(err) { showToast('Erreur : ' + err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearJobs(printer) {
|
||||||
|
if (!confirm(`Vider toute la file CUPS de ${printer} ?`)) return;
|
||||||
|
try {
|
||||||
|
await api('POST', `/api/print/cups/cancel/${printer}`);
|
||||||
|
showToast('File vidée', 'success');
|
||||||
|
setTimeout(refreshPrinters, 500);
|
||||||
|
} catch(err) { showToast('Erreur', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(jobId) {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/api/print/printers/jobs/${jobId}`);
|
||||||
|
showToast(`Job ${jobId} annulé`, 'info');
|
||||||
|
setTimeout(refreshPrinters, 500);
|
||||||
|
} catch(err) { showToast('Erreur', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── File d'attente ─────────────────────────────────────────────────────────────
|
// ── File d'attente ─────────────────────────────────────────────────────────────
|
||||||
async function refreshQueue() {
|
async function refreshQueue() {
|
||||||
try {
|
try {
|
||||||
const data = await api('GET', '/api/print/queue');
|
const data = await api('GET', '/api/print/queue');
|
||||||
const queue = data.queue || [];
|
const queue = data.queue || [];
|
||||||
const pending = queue.filter(q => q.status === 'pending');
|
const pending = queue.filter(q => q.status === 'pending').length;
|
||||||
document.getElementById('pending-count').textContent = pending.length;
|
document.getElementById('pending-count').textContent = pending;
|
||||||
|
|
||||||
const tbody = document.getElementById('queue-tbody');
|
// Mise à jour des badges de statut existants
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
// Mise à jour des statuts existants
|
|
||||||
queue.forEach(q => {
|
queue.forEach(q => {
|
||||||
const row = document.getElementById('row-' + q.id);
|
const row = document.getElementById('row-' + q.id);
|
||||||
if (row) {
|
if (row) {
|
||||||
const badge = row.querySelector('.badge');
|
const badge = row.querySelector('.badge');
|
||||||
if (badge) badge.textContent = q.status;
|
if (badge && q.status !== badge.textContent.trim()) {
|
||||||
|
const labels = { pending:'En attente', done:'Imprimé', printing:'En cours', cancelled:'Annulé', error:'Erreur' };
|
||||||
|
badge.textContent = labels[q.status] || q.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch(e) {}
|
} catch(err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executePrint(id) {
|
async function executePrint(id) {
|
||||||
@@ -158,33 +375,30 @@ async function executePrint(id) {
|
|||||||
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
|
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
|
||||||
try {
|
try {
|
||||||
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
|
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
|
||||||
showToast(r.success ? '✅ Impression lancée sur ' + r.printer : '❌ ' + r.error, r.success ? 'success' : 'error');
|
showToast(r.success ? `✅ Impression lancée (${r.printer})` : `❌ ${r.error}`, r.success ? 'success' : 'error');
|
||||||
setTimeout(refreshQueue, 1000);
|
setTimeout(refreshQueue, 1200);
|
||||||
} catch(e) { showToast('Erreur impression', 'error'); }
|
} catch(err) { showToast('Erreur impression', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelPrint(id) {
|
async function cancelPrint(id) {
|
||||||
try {
|
try {
|
||||||
await api('POST', `/api/print/cancel/${id}`);
|
await api('POST', `/api/print/cancel/${id}`);
|
||||||
showToast('Annulé', 'info');
|
showToast('Impression annulée', 'info');
|
||||||
const row = document.getElementById('row-' + id);
|
const row = document.getElementById('row-' + id);
|
||||||
if (row) row.remove();
|
if (row) row.style.opacity = '.4';
|
||||||
} catch(e) { showToast('Erreur', 'error'); }
|
setTimeout(refreshQueue, 500);
|
||||||
}
|
} catch(err) { showToast('Erreur', 'error'); }
|
||||||
|
|
||||||
async function cancelCupsJobs(printer) {
|
|
||||||
if (!confirm(`Vider la file de ${printer} ?`)) return;
|
|
||||||
try {
|
|
||||||
const r = await api('POST', `/api/print/cups/cancel/${printer}`);
|
|
||||||
showToast(r.ok ? 'File vidée' : 'Erreur', r.ok ? 'success' : 'error');
|
|
||||||
refreshQueue();
|
|
||||||
} catch(e) { showToast('Erreur', 'error'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WebSocket + auto-refresh ───────────────────────────────────────────────────
|
||||||
function onWsMessage(msg) {
|
function onWsMessage(msg) {
|
||||||
if (['print_request','print_result','print_cancelled'].includes(msg.type)) refreshQueue();
|
if (['print_request','print_result','print_cancelled'].includes(msg.type)) {
|
||||||
|
refreshQueue();
|
||||||
|
if (msg.type === 'print_result') refreshPrinters();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(refreshQueue, 10000);
|
setInterval(refreshQueue, 10000);
|
||||||
|
setInterval(refreshPrinters, 15000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,823 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Réglages bouton — JH Photomaton{% endblock %}
|
||||||
|
|
||||||
|
{% block nav_links %}
|
||||||
|
<a href="/admin">Dashboard</a>
|
||||||
|
<a href="/admin/gallery">Galerie</a>
|
||||||
|
<a href="/admin/actions">Actions</a>
|
||||||
|
<a href="/admin/print">Impression</a>
|
||||||
|
<a href="/admin/settings" class="active">Réglages</a>
|
||||||
|
<a href="/admin/logout">Déconnexion</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sliders ─────────────────────────────────────────────────────────────── */
|
||||||
|
.timing-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.timing-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.timing-label span:first-child { color: var(--text-muted); font-size: .9rem; }
|
||||||
|
.timing-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
min-width: 5ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle-switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute; inset: 0; cursor: pointer;
|
||||||
|
background: var(--border); border-radius: 26px; transition: .3s;
|
||||||
|
}
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
width: 20px; height: 20px; left: 3px; bottom: 3px;
|
||||||
|
background: #fff; border-radius: 50%; transition: .3s;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider { background: var(--primary); }
|
||||||
|
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(22px); }
|
||||||
|
|
||||||
|
input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
height: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.timing-hint { font-size: .78rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
select.field {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .45rem .7rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row { display: flex; gap: .75rem; margin-top: 1.5rem; flex-wrap: wrap; }
|
||||||
|
.btn { padding: .55rem 1.2rem; border: none; border-radius: 8px; cursor: pointer; font-size: .95rem; font-weight: 600; transition: opacity .2s; }
|
||||||
|
.btn:hover { opacity: .85; }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; }
|
||||||
|
.btn-success { background: #1a7340; color: #fff; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-sm { padding: .35rem .8rem; font-size: .85rem; }
|
||||||
|
|
||||||
|
/* ── Testeur ─────────────────────────────────────────────────────────────── */
|
||||||
|
.tester-area { display: flex; flex-direction: column; align-items: center; gap: 1.25rem; }
|
||||||
|
|
||||||
|
#big-btn {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #2a6db0, #0d3d6b);
|
||||||
|
border: 4px solid var(--primary);
|
||||||
|
box-shadow: 0 0 0 0 rgba(25, 108, 176, .5);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: transform .1s, box-shadow .1s;
|
||||||
|
color: #fff;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#big-btn:active, #big-btn.pressed {
|
||||||
|
transform: scale(.94);
|
||||||
|
box-shadow: 0 0 0 16px rgba(25, 108, 176, .2);
|
||||||
|
}
|
||||||
|
#big-btn .btn-icon { font-size: 2.2rem; pointer-events: none; }
|
||||||
|
#big-btn .btn-hint { font-size: .75rem; opacity: .7; pointer-events: none; }
|
||||||
|
|
||||||
|
/* Anneau de compte pendant multi-clic */
|
||||||
|
.click-dots { display: flex; gap: .4rem; justify-content: center; }
|
||||||
|
.click-dot {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.click-dot.active { background: var(--primary); }
|
||||||
|
.click-dot.max { background: #1a7340; }
|
||||||
|
|
||||||
|
/* Résultat */
|
||||||
|
.result-box {
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .5rem 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
transition: background .3s, color .3s;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.result-box.res-click { background: rgba(25,108,176,.2); color: var(--primary); }
|
||||||
|
.result-box.res-long { background: rgba(224,123,0,.2); color: #e07b00; }
|
||||||
|
.result-box.res-error { background: rgba(192,0,0,.2); color: #e05050; }
|
||||||
|
|
||||||
|
/* Log */
|
||||||
|
.event-log {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: .15rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||||
|
animation: fadeIn .3s ease;
|
||||||
|
}
|
||||||
|
.log-entry .log-val { color: var(--text); font-weight: 600; }
|
||||||
|
.log-entry .log-src { font-size: .75rem; opacity: .6; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* ── GPIO live ───────────────────────────────────────────────────────────── */
|
||||||
|
.gpio-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.gpio-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
|
||||||
|
.gpio-dot.live { background: #1a7340; animation: pulse 1s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
||||||
|
|
||||||
|
/* ── Status banner ───────────────────────────────────────────────────────── */
|
||||||
|
.status-banner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.banner { padding: .65rem 1rem; border-radius: 8px; font-size: .9rem; display: none; }
|
||||||
|
.banner.show { display: block; }
|
||||||
|
.banner-ok { background: rgba(26,115,64,.15); border: 1px solid #1a7340; color: #4caf50; }
|
||||||
|
.banner-err { background: rgba(192,0,0,.12); border: 1px solid #c00; color: #e05050; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="status-banner">
|
||||||
|
<div class="banner banner-ok" id="banner-ok">✅ Réglages appliqués avec succès.</div>
|
||||||
|
<div class="banner banner-err" id="banner-err">❌ Erreur lors de l'application des réglages.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
|
||||||
|
<!-- ── COLONNE GAUCHE : Timings ─────────────────────────────────────────── -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">⏱ Timing des clics</div>
|
||||||
|
|
||||||
|
<!-- Fenêtre multi-clic -->
|
||||||
|
<div class="timing-row">
|
||||||
|
<div class="timing-label">
|
||||||
|
<span>Fenêtre multi-clic</span>
|
||||||
|
<span class="timing-value" id="val-dc">{{ button.double_click_ms }} ms</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="double-click-ms"
|
||||||
|
min="150" max="800" step="25"
|
||||||
|
value="{{ button.double_click_ms }}"
|
||||||
|
oninput="updateSlider('dc', this.value, ' ms')">
|
||||||
|
<div class="timing-hint">
|
||||||
|
Délai d'attente entre deux clics. Trop court = double-clic raté. Trop long = lenteur.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Long press -->
|
||||||
|
<div class="timing-row">
|
||||||
|
<div class="timing-label">
|
||||||
|
<span>Durée appui long</span>
|
||||||
|
<span class="timing-value" id="val-lp">{{ button.long_press_ms }} ms</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="long-press-ms"
|
||||||
|
min="500" max="3000" step="100"
|
||||||
|
value="{{ button.long_press_ms }}"
|
||||||
|
oninput="updateSlider('lp', this.value, ' ms')">
|
||||||
|
<div class="timing-hint">
|
||||||
|
Durée minimale pour déclencher "impression" en maintenant le bouton appuyé.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anti-rebond -->
|
||||||
|
<div class="timing-row">
|
||||||
|
<div class="timing-label">
|
||||||
|
<span>Anti-rebond (debounce)</span>
|
||||||
|
<span class="timing-value" id="val-db">{{ button.debounce_ms }} ms</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="debounce-ms"
|
||||||
|
min="10" max="200" step="5"
|
||||||
|
value="{{ button.debounce_ms }}"
|
||||||
|
oninput="updateSlider('db', this.value, ' ms')">
|
||||||
|
<div class="timing-hint">
|
||||||
|
Filtre les faux contacts mécaniques. Augmenter si le bouton "rebondit".
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nombre max de clics -->
|
||||||
|
<div class="timing-row">
|
||||||
|
<div class="timing-label">
|
||||||
|
<span>Nombre max de clics détectés</span>
|
||||||
|
</div>
|
||||||
|
<select class="field" id="max-clicks">
|
||||||
|
{% for n in [1,2,3,4] %}
|
||||||
|
<option value="{{ n }}" {% if n == button.max_clicks %}selected{% endif %}>
|
||||||
|
{{ n }} clic{{ 's' if n > 1 else '' }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="timing-hint">
|
||||||
|
Correspond au nombre d'actions mappées (voir page Actions).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Impression long press -->
|
||||||
|
<div class="timing-row">
|
||||||
|
<div class="timing-label">
|
||||||
|
<span>Impression via appui long</span>
|
||||||
|
<select class="field" id="print-enabled" style="width:auto">
|
||||||
|
<option value="true" {% if button.print_enabled %}selected{% endif %}>Activée</option>
|
||||||
|
<option value="false" {% if not button.print_enabled %}selected{% endif %}>Désactivée</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" onclick="applyTimings(false)">
|
||||||
|
▶ Appliquer (sans redémarrage)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" onclick="applyTimings(true)">
|
||||||
|
💾 Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
|
||||||
|
<strong>Appliquer</strong> met les réglages en ligne immédiatement.<br>
|
||||||
|
<strong>Sauvegarder</strong> les persiste dans <code>settings.yaml</code> pour les redémarrages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contrôles interface photobooth-app -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">🎛 Interface photobooth-app</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.5rem 0; border-bottom:1px solid var(--border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:.95rem; font-weight:600;">Bouton Supprimer (review)</div>
|
||||||
|
<div style="font-size:.78rem; color:var(--text-muted); margin-top:.2rem;">
|
||||||
|
Affiche ou cache le bouton 🗑 sur l'écran de validation après chaque capture.<br>
|
||||||
|
Effectif immédiatement (modifie <code>userdata/private.css</code>).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch" title="Bouton Supprimer visible dans photobooth-app">
|
||||||
|
<input type="checkbox" id="toggle-delete-btn"
|
||||||
|
{% if config.photobooth.show_delete_button %}checked{% endif %}
|
||||||
|
onchange="setDeleteButton(this.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:.75rem; margin-top:.75rem; font-size:.82rem; color:var(--text-muted);" id="delete-btn-status">
|
||||||
|
{% if config.photobooth.show_delete_button %}
|
||||||
|
✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>
|
||||||
|
{% else %}
|
||||||
|
🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions système -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">🖥 Affichage HDMI</div>
|
||||||
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||||
|
Rafraîchit le Chromium en kiosque connecté à l'écran HDMI.
|
||||||
|
Équivalent à appuyer sur <code>F5</code> sur le Pi.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-outline" onclick="refreshScreen()" id="btn-refresh-screen">
|
||||||
|
🔄 Rafraîchir l'écran (F5)
|
||||||
|
</button>
|
||||||
|
<span id="refresh-status" style="font-size:.82rem; color:var(--text-muted); margin-left:.75rem;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Flash LED -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">⚡ Flash photo (LED)</div>
|
||||||
|
<p style="font-size:.82rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||||
|
Les LEDs WS2812b ont une diode bleue plus efficace que la rouge.<br>
|
||||||
|
<code>[255,255,255]</code> produit un rendu à dominante bleue sur les photos.<br>
|
||||||
|
Réduisez le bleu et augmentez le rouge pour un blanc plus naturel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:.75rem;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted)">Couleur flash (R G B)</label>
|
||||||
|
<div style="display:flex; gap:.5rem; align-items:center; margin-top:.25rem; flex-wrap:wrap;">
|
||||||
|
<input type="number" id="flash-r" min="0" max="255" value="255"
|
||||||
|
style="width:70px" class="form-control" placeholder="R">
|
||||||
|
<input type="number" id="flash-g" min="0" max="255" value="200"
|
||||||
|
style="width:70px" class="form-control" placeholder="G">
|
||||||
|
<input type="number" id="flash-b" min="0" max="255" value="80"
|
||||||
|
style="width:70px" class="form-control" placeholder="B">
|
||||||
|
<div id="flash-preview" style="width:36px;height:36px;border-radius:50%;border:2px solid var(--border);background:rgb(255,200,80)"></div>
|
||||||
|
<input type="color" id="flash-colorpicker" value="#ffc850"
|
||||||
|
title="Aide visuelle (convertit en RGB approx)"
|
||||||
|
style="width:36px;height:36px;cursor:pointer;border:none;background:none"
|
||||||
|
onchange="colorPickerToRgb(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted)">
|
||||||
|
Durée du flash : <strong id="flash-dur-val">0.30</strong> s
|
||||||
|
</label>
|
||||||
|
<input type="range" id="flash-duration" min="0.05" max="1.0" step="0.05" value="0.30"
|
||||||
|
oninput="document.getElementById('flash-dur-val').textContent=parseFloat(this.value).toFixed(2)"
|
||||||
|
style="width:100%">
|
||||||
|
<div style="font-size:.72rem;color:var(--text-muted)">0.05 s (bref) — 1.0 s (long)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted)">Nombre de flashs</label>
|
||||||
|
<select id="flash-count" class="form-control" style="width:100px; margin-top:.25rem">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="previewFlash()">💡 Tester le flash</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveFlash()">💾 Sauvegarder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.25rem;">
|
||||||
|
<span style="font-size:.75rem; color:var(--text-muted); align-self:center;">Presets :</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,255,255,0.15,2)">Blanc pur (froid)</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,200,80,0.30,2)">Blanc chaud ★</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,220,120,0.25,2)">Blanc neutre</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,180,40,0.35,2)">Ambre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valeurs recommandées -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">📋 Préréglages</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(350, 1200, 30, 4)">Rapide</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(400, 1500, 50, 4)">Normal (défaut)</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(500, 2000, 80, 4)">Lent / senior</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(600, 2500, 100, 4)">Très lent</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
|
||||||
|
Les préréglages mettent à jour les sliders uniquement.
|
||||||
|
Cliquez ensuite sur Appliquer ou Sauvegarder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── COLONNE DROITE : Testeur ─────────────────────────────────────────── -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">🎯 Testeur de timings</div>
|
||||||
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||||
|
Simulation <strong>locale</strong> — utilise les valeurs des sliders en temps réel,
|
||||||
|
même avant de sauvegarder. Aucune action GPIO déclenchée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tester-area">
|
||||||
|
|
||||||
|
<!-- Anneau de clics -->
|
||||||
|
<div class="click-dots" id="click-dots">
|
||||||
|
<div class="click-dot" id="dot-1"></div>
|
||||||
|
<div class="click-dot" id="dot-2"></div>
|
||||||
|
<div class="click-dot" id="dot-3"></div>
|
||||||
|
<div class="click-dot" id="dot-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grand bouton -->
|
||||||
|
<div id="big-btn"
|
||||||
|
onmousedown="btnDown(event)"
|
||||||
|
onmouseup="btnUp(event)"
|
||||||
|
onmouseleave="btnUp(event)"
|
||||||
|
ontouchstart="btnDown(event)"
|
||||||
|
ontouchend="btnUp(event)">
|
||||||
|
<span class="btn-icon">👆</span>
|
||||||
|
<span>Appuyer ici</span>
|
||||||
|
<span class="btn-hint">clic / multi-clic / maintenir</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultat -->
|
||||||
|
<div class="result-box" id="result-box">En attente…</div>
|
||||||
|
|
||||||
|
<!-- Log local -->
|
||||||
|
<div class="event-log" id="tester-log">
|
||||||
|
<div style="opacity:.4; text-align:center; padding:.5rem 0">Les événements apparaîtront ici</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GPIO réel via WebSocket -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">
|
||||||
|
🔌 Bouton GPIO réel
|
||||||
|
<div class="gpio-badge" style="margin-left:auto">
|
||||||
|
<div class="gpio-dot" id="gpio-dot"></div>
|
||||||
|
<span id="gpio-label">En attente…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:.75rem;">
|
||||||
|
Événements reçus du bouton physique (GPIO {{ button.pin }}) via WebSocket.
|
||||||
|
</p>
|
||||||
|
<div class="event-log" id="gpio-log">
|
||||||
|
<div style="opacity:.4; text-align:center; padding:.5rem 0">Appuyer sur le bouton physique…</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(1)">Sim. 1 clic</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(2)">Sim. 2 clics</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(3)">Sim. 3 clics</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(4)">Sim. 4 clics</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(0)">Sim. Long press</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Sliders & valeurs
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function updateSlider(key, val, unit) {
|
||||||
|
document.getElementById('val-' + key).textContent = val + unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimings() {
|
||||||
|
return {
|
||||||
|
doubleClickMs: parseInt(document.getElementById('double-click-ms').value),
|
||||||
|
longPressMs: parseInt(document.getElementById('long-press-ms').value),
|
||||||
|
debounceMs: parseInt(document.getElementById('debounce-ms').value),
|
||||||
|
maxClicks: parseInt(document.getElementById('max-clicks').value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(dc, lp, db, mc) {
|
||||||
|
document.getElementById('double-click-ms').value = dc;
|
||||||
|
document.getElementById('long-press-ms').value = lp;
|
||||||
|
document.getElementById('debounce-ms').value = db;
|
||||||
|
document.getElementById('max-clicks').value = mc;
|
||||||
|
updateSlider('dc', dc, ' ms');
|
||||||
|
updateSlider('lp', lp, ' ms');
|
||||||
|
updateSlider('db', db, ' ms');
|
||||||
|
showDots(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Apply / Save via API
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function applyTimings(save) {
|
||||||
|
const t = getTimings();
|
||||||
|
const printEnabled = document.getElementById('print-enabled').value === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await api('PUT', '/api/system/button/config', {
|
||||||
|
double_click_ms: t.doubleClickMs,
|
||||||
|
long_press_ms: t.longPressMs,
|
||||||
|
debounce_ms: t.debounceMs,
|
||||||
|
max_clicks: t.maxClicks,
|
||||||
|
print_enabled: printEnabled,
|
||||||
|
save: save,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('banner-ok').className = 'banner banner-ok show';
|
||||||
|
document.getElementById('banner-err').className = 'banner banner-err';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('banner-ok').className = 'banner banner-ok';
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
showToast(save ? '✅ Réglages sauvegardés dans settings.yaml' : '▶ Timings appliqués (non sauvegardés)', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('banner-err').className = 'banner banner-err show';
|
||||||
|
document.getElementById('banner-ok').className = 'banner banner-ok';
|
||||||
|
showToast('Erreur : ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Testeur de timings (simulation locale — AUCUN GPIO)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
let testerClickCount = 0;
|
||||||
|
let testerClickTimer = null;
|
||||||
|
let testerLongTimer = null;
|
||||||
|
let testerLongFired = false;
|
||||||
|
|
||||||
|
function btnDown(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('big-btn').classList.add('pressed');
|
||||||
|
testerLongFired = false;
|
||||||
|
if (testerLongTimer) clearTimeout(testerLongTimer);
|
||||||
|
const { longPressMs } = getTimings();
|
||||||
|
|
||||||
|
testerLongTimer = setTimeout(() => {
|
||||||
|
testerLongFired = true;
|
||||||
|
if (testerClickTimer) clearTimeout(testerClickTimer);
|
||||||
|
testerClickCount = 0;
|
||||||
|
showDots(0);
|
||||||
|
showTesterResult('LONG PRESS', 'long');
|
||||||
|
addLog('tester-log', 'Long press', 'testeur');
|
||||||
|
}, longPressMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function btnUp(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('big-btn').classList.remove('pressed');
|
||||||
|
if (testerLongTimer) clearTimeout(testerLongTimer);
|
||||||
|
if (testerLongFired) return;
|
||||||
|
|
||||||
|
const { doubleClickMs, maxClicks } = getTimings();
|
||||||
|
testerClickCount++;
|
||||||
|
showDots(testerClickCount);
|
||||||
|
|
||||||
|
if (testerClickTimer) clearTimeout(testerClickTimer);
|
||||||
|
|
||||||
|
const delay = testerClickCount >= maxClicks ? 50 : doubleClickMs;
|
||||||
|
|
||||||
|
testerClickTimer = setTimeout(() => {
|
||||||
|
const n = testerClickCount;
|
||||||
|
testerClickCount = 0;
|
||||||
|
testerClickTimer = null;
|
||||||
|
showDots(0);
|
||||||
|
const label = n === 1 ? '1 CLIC' : `${n} CLICS`;
|
||||||
|
showTesterResult(label, 'click');
|
||||||
|
addLog('tester-log', label, 'testeur');
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDots(count) {
|
||||||
|
const max = parseInt(document.getElementById('max-clicks').value);
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const d = document.getElementById('dot-' + i);
|
||||||
|
if (i > max) {
|
||||||
|
d.className = 'click-dot';
|
||||||
|
d.style.opacity = '.2';
|
||||||
|
} else {
|
||||||
|
d.style.opacity = '1';
|
||||||
|
d.className = 'click-dot' + (i <= count ? (count >= max ? ' max' : ' active') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTesterResult(label, type) {
|
||||||
|
const box = document.getElementById('result-box');
|
||||||
|
box.textContent = label;
|
||||||
|
box.className = 'result-box res-' + (type === 'long' ? 'long' : type === 'click' ? 'click' : 'error');
|
||||||
|
setTimeout(() => { box.className = 'result-box'; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Simulation GPIO serveur
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function simulateGPIO(clicks) {
|
||||||
|
try {
|
||||||
|
await api('POST', `/api/system/button/simulate?clicks=${clicks}`);
|
||||||
|
showToast(`Simulation envoyée : ${clicks === 0 ? 'long press' : clicks + ' clic(s)'}`, 'info');
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Erreur simulation : ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// WebSocket — écoute du bouton GPIO réel
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function onWsMessage(msg) {
|
||||||
|
if (msg.type !== 'button_event') return;
|
||||||
|
|
||||||
|
const dot = document.getElementById('gpio-dot');
|
||||||
|
const label = document.getElementById('gpio-label');
|
||||||
|
|
||||||
|
dot.classList.add('live');
|
||||||
|
setTimeout(() => dot.classList.remove('live'), 1500);
|
||||||
|
|
||||||
|
let text;
|
||||||
|
if (msg.clicks === 0) {
|
||||||
|
text = 'LONG PRESS → Impression';
|
||||||
|
label.textContent = '🖨 Long press';
|
||||||
|
} else {
|
||||||
|
const clicks = msg.clicks;
|
||||||
|
text = `${clicks} CLIC${clicks > 1 ? 'S' : ''} → ${msg.action || ''}`;
|
||||||
|
label.textContent = `${clicks} clic${clicks > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog('gpio-log', text, 'GPIO');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Helpers
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function addLog(targetId, value, source) {
|
||||||
|
const log = document.getElementById(targetId);
|
||||||
|
|
||||||
|
// Vider le placeholder si présent
|
||||||
|
if (log.querySelector('[style*="opacity"]')) log.innerHTML = '';
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry';
|
||||||
|
const now = new Date().toLocaleTimeString('fr-FR');
|
||||||
|
entry.innerHTML = `
|
||||||
|
<span class="log-val">${value}</span>
|
||||||
|
<span class="log-src">[${source}] ${now}</span>
|
||||||
|
`;
|
||||||
|
log.insertBefore(entry, log.firstChild);
|
||||||
|
|
||||||
|
// Garder 20 lignes max
|
||||||
|
while (log.children.length > 20) log.removeChild(log.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bouton Supprimer photobooth-app
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function setDeleteButton(visible) {
|
||||||
|
try {
|
||||||
|
const r = await api('POST', `/api/system/ui/delete-button?visible=${visible}`);
|
||||||
|
const status = document.getElementById('delete-btn-status');
|
||||||
|
if (r.ok) {
|
||||||
|
status.innerHTML = visible
|
||||||
|
? '✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>'
|
||||||
|
: '🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>';
|
||||||
|
showToast(visible ? '✅ Bouton Supprimer activé' : '🚫 Bouton Supprimer désactivé', 'success');
|
||||||
|
} else {
|
||||||
|
showToast('❌ Erreur écriture private.css', 'error');
|
||||||
|
// Remettre le toggle dans l'état précédent
|
||||||
|
document.getElementById('toggle-delete-btn').checked = !visible;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Erreur : ' + e.message, 'error');
|
||||||
|
document.getElementById('toggle-delete-btn').checked = !visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Refresh écran HDMI
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function refreshScreen() {
|
||||||
|
const btn = document.getElementById('btn-refresh-screen');
|
||||||
|
const status = document.getElementById('refresh-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Envoi…';
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/system/screen/refresh');
|
||||||
|
status.style.color = '#4caf50';
|
||||||
|
status.textContent = r.ok ? `✅ OK (${r.method})` : `⚠ ${r.error}`;
|
||||||
|
showToast('Écran rafraîchi', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
status.style.color = '#e05050';
|
||||||
|
status.textContent = '❌ Erreur : ' + e.message;
|
||||||
|
showToast('Erreur refresh : ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Flash LED
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function loadFlashConfig() {
|
||||||
|
try {
|
||||||
|
const effects = await api('GET', '/api/leds/effects');
|
||||||
|
const cap = effects.capture;
|
||||||
|
if (!cap) return;
|
||||||
|
document.getElementById('flash-r').value = cap.color[0] ?? 255;
|
||||||
|
document.getElementById('flash-g').value = cap.color[1] ?? 200;
|
||||||
|
document.getElementById('flash-b').value = cap.color[2] ?? 80;
|
||||||
|
document.getElementById('flash-duration').value = cap.flash_duration ?? 0.30;
|
||||||
|
document.getElementById('flash-dur-val').textContent = parseFloat(cap.flash_duration ?? 0.30).toFixed(2);
|
||||||
|
document.getElementById('flash-count').value = cap.flashes ?? 2;
|
||||||
|
updateFlashPreview();
|
||||||
|
} catch(e) { console.warn('loadFlashConfig:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFlashPreview() {
|
||||||
|
const r = parseInt(document.getElementById('flash-r').value) || 0;
|
||||||
|
const g = parseInt(document.getElementById('flash-g').value) || 0;
|
||||||
|
const b = parseInt(document.getElementById('flash-b').value) || 0;
|
||||||
|
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
|
||||||
|
['flash-r','flash-g','flash-b'].forEach(id =>
|
||||||
|
document.getElementById(id).addEventListener('input', updateFlashPreview, {once:true})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorPickerToRgb(hex) {
|
||||||
|
const r = parseInt(hex.slice(1,3),16);
|
||||||
|
const g = parseInt(hex.slice(3,5),16);
|
||||||
|
const b = parseInt(hex.slice(5,7),16);
|
||||||
|
document.getElementById('flash-r').value = r;
|
||||||
|
document.getElementById('flash-g').value = g;
|
||||||
|
document.getElementById('flash-b').value = b;
|
||||||
|
updateFlashPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFlashPreset(r, g, b, dur, flashes) {
|
||||||
|
document.getElementById('flash-r').value = r;
|
||||||
|
document.getElementById('flash-g').value = g;
|
||||||
|
document.getElementById('flash-b').value = b;
|
||||||
|
document.getElementById('flash-duration').value = dur;
|
||||||
|
document.getElementById('flash-dur-val').textContent = dur.toFixed(2);
|
||||||
|
document.getElementById('flash-count').value = flashes;
|
||||||
|
updateFlashPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewFlash() {
|
||||||
|
await api('POST', '/api/leds/effect?effect=capture');
|
||||||
|
showToast('Flash test declenche', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFlash() {
|
||||||
|
const r = parseInt(document.getElementById('flash-r').value);
|
||||||
|
const g = parseInt(document.getElementById('flash-g').value);
|
||||||
|
const b = parseInt(document.getElementById('flash-b').value);
|
||||||
|
const dur = parseFloat(document.getElementById('flash-duration').value);
|
||||||
|
const flashes = parseInt(document.getElementById('flash-count').value);
|
||||||
|
try {
|
||||||
|
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${dur}&flashes=${flashes}&save=true`);
|
||||||
|
showToast('Flash sauvegarde', 'success');
|
||||||
|
updateFlashPreview();
|
||||||
|
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liaison input -> preview
|
||||||
|
['flash-r','flash-g','flash-b'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('input', updateFlashPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadFlashConfig();
|
||||||
|
|
||||||
|
// Init dots
|
||||||
|
showDots(0);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,16 +1,4 @@
|
|||||||
"""
|
"""JH Photomaton -- Interface de gestion (Les Sapins Du Web)."""
|
||||||
JH Photomaton — Interface de gestion
|
|
||||||
Raspberry Pi 4 / Les Sapins Du Web (LSDW)
|
|
||||||
|
|
||||||
Remplace Node-RED pour :
|
|
||||||
- Contrôle bouton GPIO23 (multi-clic + long press) + relay GPIO12
|
|
||||||
- LEDs WS2812b GPIO18 (35 LEDs) — effets selon état
|
|
||||||
- Galerie publique et galerie admin
|
|
||||||
- File d'attente d'impression (SQLite)
|
|
||||||
- Dashboard admin avec monitoring système
|
|
||||||
|
|
||||||
Port : 8090 (configurable dans config/settings.yaml)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -22,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from backend.services.config_service import ConfigService
|
from backend.services.config_service import ConfigService
|
||||||
|
from backend.services.event_service import EventService
|
||||||
from backend.services.led_service import LEDService
|
from backend.services.led_service import LEDService
|
||||||
from backend.services.button_service import ButtonService
|
from backend.services.button_service import ButtonService
|
||||||
from backend.services.photobooth_service import PhotoboothService
|
from backend.services.photobooth_service import PhotoboothService
|
||||||
@@ -37,6 +26,7 @@ from backend.api import (
|
|||||||
print_api,
|
print_api,
|
||||||
actions_api,
|
actions_api,
|
||||||
webhooks,
|
webhooks,
|
||||||
|
event_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -50,23 +40,27 @@ BASE_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# ── Chargement config ───────────────────────────────────────────────────
|
|
||||||
config_svc = ConfigService(BASE_DIR / "config" / "settings.yaml")
|
config_svc = ConfigService(BASE_DIR / "config" / "settings.yaml")
|
||||||
config = config_svc.load()
|
config = config_svc.load()
|
||||||
|
|
||||||
# ── Services ────────────────────────────────────────────────────────────
|
|
||||||
ws_manager = WSManager()
|
ws_manager = WSManager()
|
||||||
photobooth_svc = PhotoboothService(config)
|
photobooth_svc = PhotoboothService(config)
|
||||||
system_svc = SystemService()
|
system_svc = SystemService()
|
||||||
printer_svc = PrinterService(config)
|
printer_svc = PrinterService(config)
|
||||||
|
event_svc = EventService()
|
||||||
|
|
||||||
await printer_svc.init_db(BASE_DIR / "data" / "print_queue.db")
|
await printer_svc.init_db(BASE_DIR / "data" / "print_queue.db")
|
||||||
|
await event_svc.init_db(BASE_DIR / "data" / "events.db")
|
||||||
|
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
ev = config.event
|
||||||
|
if not ev.started_at:
|
||||||
|
ev.started_at = _dt.now().timestamp()
|
||||||
|
await event_svc.ensure_event(ev.slug, ev.name, ev.started_at)
|
||||||
|
|
||||||
# LEDs
|
|
||||||
led_svc = LEDService(config)
|
led_svc = LEDService(config)
|
||||||
led_svc.start()
|
led_svc.start()
|
||||||
|
|
||||||
# Bouton GPIO — passe le loop asyncio pour les callbacks
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
led_svc.set_loop(loop)
|
led_svc.set_loop(loop)
|
||||||
led_svc.set_on_change(ws_manager.broadcast)
|
led_svc.set_on_change(ws_manager.broadcast)
|
||||||
@@ -74,7 +68,6 @@ async def lifespan(app: FastAPI):
|
|||||||
button_svc = ButtonService(config, led_svc, photobooth_svc, ws_manager, loop)
|
button_svc = ButtonService(config, led_svc, photobooth_svc, ws_manager, loop)
|
||||||
button_svc.start()
|
button_svc.start()
|
||||||
|
|
||||||
# ── État partagé dans l'app ──────────────────────────────────────────────
|
|
||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.state.config_service = config_svc
|
app.state.config_service = config_svc
|
||||||
app.state.ws_manager = ws_manager
|
app.state.ws_manager = ws_manager
|
||||||
@@ -83,26 +76,25 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.photobooth_service = photobooth_svc
|
app.state.photobooth_service = photobooth_svc
|
||||||
app.state.printer_service = printer_svc
|
app.state.printer_service = printer_svc
|
||||||
app.state.system_service = system_svc
|
app.state.system_service = system_svc
|
||||||
|
app.state.event_service = event_svc
|
||||||
|
|
||||||
# ── Tâche de fond : broadcast stats système ──────────────────────────────
|
|
||||||
stats_task = asyncio.create_task(_system_stats_loop(system_svc, ws_manager))
|
stats_task = asyncio.create_task(_system_stats_loop(system_svc, ws_manager))
|
||||||
|
|
||||||
logger.info("JH Photomaton démarré — port %d", config.app.port)
|
logger.info("JH Photomaton demarre -- port %d", config.app.port)
|
||||||
led_svc.play("idle")
|
led_svc.play("idle")
|
||||||
|
|
||||||
yield # ← l'app tourne ici
|
yield
|
||||||
|
|
||||||
# ── Arrêt propre ─────────────────────────────────────────────────────────
|
|
||||||
stats_task.cancel()
|
stats_task.cancel()
|
||||||
button_svc.stop()
|
button_svc.stop()
|
||||||
led_svc.stop()
|
led_svc.stop()
|
||||||
await photobooth_svc.close()
|
await photobooth_svc.close()
|
||||||
await printer_svc.close()
|
await printer_svc.close()
|
||||||
logger.info("JH Photomaton arrêté")
|
await event_svc.close()
|
||||||
|
logger.info("JH Photomaton arrete")
|
||||||
|
|
||||||
|
|
||||||
async def _system_stats_loop(sys_svc: SystemService, ws: WSManager):
|
async def _system_stats_loop(sys_svc: SystemService, ws: WSManager):
|
||||||
"""Broadcast des stats système toutes les 5 secondes."""
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
stats = sys_svc.get_stats()
|
stats = sys_svc.get_stats()
|
||||||
@@ -112,29 +104,21 @@ async def _system_stats_loop(sys_svc: SystemService, ws: WSManager):
|
|||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
# ── Application FastAPI ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="JH Photomaton",
|
title="JH Photomaton",
|
||||||
description="Interface de gestion du photomaton — Les Sapins Du Web",
|
description="Interface de gestion du photomaton -- Les Sapins Du Web",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
SessionMiddleware,
|
SessionMiddleware,
|
||||||
secret_key="jh-photomaton-session-2026", # remplacé par config au démarrage
|
secret_key="jh-photomaton-session-2026",
|
||||||
max_age=86400, # 24h
|
max_age=86400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fichiers statiques
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "frontend" / "static"), name="static")
|
||||||
app.mount(
|
|
||||||
"/static",
|
|
||||||
StaticFiles(directory=BASE_DIR / "frontend" / "static"),
|
|
||||||
name="static",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Routes
|
|
||||||
app.include_router(gallery.router)
|
app.include_router(gallery.router)
|
||||||
app.include_router(admin_api.router)
|
app.include_router(admin_api.router)
|
||||||
app.include_router(admin_gallery_api.router)
|
app.include_router(admin_gallery_api.router)
|
||||||
@@ -143,16 +127,13 @@ app.include_router(system_api.router, prefix="/api")
|
|||||||
app.include_router(print_api.router, prefix="/api")
|
app.include_router(print_api.router, prefix="/api")
|
||||||
app.include_router(actions_api.router, prefix="/api")
|
app.include_router(actions_api.router, prefix="/api")
|
||||||
app.include_router(webhooks.router, prefix="/api")
|
app.include_router(webhooks.router, prefix="/api")
|
||||||
|
app.include_router(event_api.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
# ── WebSocket ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
ws_manager: WSManager = websocket.app.state.ws_manager
|
ws_manager: WSManager = websocket.app.state.ws_manager
|
||||||
await ws_manager.connect(websocket)
|
await ws_manager.connect(websocket)
|
||||||
|
|
||||||
# Envoie l'état initial à la connexion
|
|
||||||
try:
|
try:
|
||||||
led = websocket.app.state.led_service
|
led = websocket.app.state.led_service
|
||||||
sys_svc = websocket.app.state.system_service
|
sys_svc = websocket.app.state.system_service
|
||||||
@@ -160,11 +141,9 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
await ws_manager.send(websocket, {"type": "system_stats", "data": sys_svc.get_stats()})
|
await ws_manager.send(websocket, {"type": "system_stats", "data": sys_svc.get_stats()})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
# Commandes entrantes du client (optionnel)
|
|
||||||
import json
|
import json
|
||||||
try:
|
try:
|
||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
@@ -176,15 +155,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
ws_manager.disconnect(websocket)
|
ws_manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
# ── Point d'entrée ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
cfg = ConfigService(BASE_DIR / "config" / "settings.yaml").load()
|
cfg = ConfigService(BASE_DIR / "config" / "settings.yaml").load()
|
||||||
uvicorn.run(
|
uvicorn.run("main:app", host=cfg.app.host, port=cfg.app.port, reload=cfg.app.debug, log_level="info")
|
||||||
"main:app",
|
|
||||||
host=cfg.app.host,
|
|
||||||
port=cfg.app.port,
|
|
||||||
reload=cfg.app.debug,
|
|
||||||
log_level="info",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# JH Photomaton — Sauvegarde des configurations système
|
||||||
|
#
|
||||||
|
# Sauvegarde : RaspAP · Zoraxy · CUPS · JH Photomaton
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# sudo bash scripts/backup-configs.sh
|
||||||
|
# sudo bash scripts/backup-configs.sh /mnt/usb/backups # dossier cible custom
|
||||||
|
#
|
||||||
|
# Les archives sont créées dans ~/photomaton-backups/ (ou le dossier fourni).
|
||||||
|
# Chaque composant génère son propre .tar.gz daté.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEST="${1:-/home/pi/photomaton-backups}"
|
||||||
|
DATE=$(date +%Y%m%d-%H%M%S)
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Couleurs
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
|
||||||
|
ok() { echo -e "${GREEN}✅ $*${NC}"; }
|
||||||
|
warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
|
||||||
|
err() { echo -e "${RED}❌ $*${NC}"; ERRORS=$((ERRORS+1)); }
|
||||||
|
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " JH Photomaton — Sauvegarde configurations"
|
||||||
|
echo " Date : $DATE"
|
||||||
|
echo " Dossier: $DEST"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. RaspAP
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "▶ Sauvegarde RaspAP..."
|
||||||
|
|
||||||
|
RASPAP_ARCHIVE="$DEST/raspap-$DATE.tar.gz"
|
||||||
|
|
||||||
|
# Fichiers de configuration RaspAP
|
||||||
|
RASPAP_FILES=(
|
||||||
|
"/etc/hostapd/hostapd.conf" # Config WiFi hotspot (SSID, password, channel)
|
||||||
|
"/etc/dnsmasq.conf" # Config DHCP/DNS principale
|
||||||
|
"/etc/dhcpcd.conf" # Config IP statique wlan0
|
||||||
|
"/etc/default/hostapd" # Active hostapd au démarrage
|
||||||
|
"/etc/raspap" # Config interface web RaspAP
|
||||||
|
"/etc/lighttpd/conf-available/50-raspap-router.conf" # Config lighttpd
|
||||||
|
"/etc/dnsmasq.d" # Configs dnsmasq additionnelles (dont notre custom DNS)
|
||||||
|
"/etc/network/interfaces" # Config réseau (si présent)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collecte uniquement les fichiers/dossiers existants
|
||||||
|
EXISTING_FILES=()
|
||||||
|
for f in "${RASPAP_FILES[@]}"; do
|
||||||
|
[ -e "$f" ] && EXISTING_FILES+=("$f") || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#EXISTING_FILES[@]} -gt 0 ]; then
|
||||||
|
tar -czf "$RASPAP_ARCHIVE" "${EXISTING_FILES[@]}" 2>/dev/null && \
|
||||||
|
ok "RaspAP → $(basename $RASPAP_ARCHIVE) ($(du -sh $RASPAP_ARCHIVE | cut -f1))" || \
|
||||||
|
err "Échec sauvegarde RaspAP"
|
||||||
|
else
|
||||||
|
warn "RaspAP : aucun fichier de config trouvé (RaspAP installé ?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. Zoraxy
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "▶ Sauvegarde Zoraxy..."
|
||||||
|
|
||||||
|
ZORAXY_DIR="/home/pi/zoraxy"
|
||||||
|
ZORAXY_ARCHIVE="$DEST/zoraxy-$DATE.tar.gz"
|
||||||
|
|
||||||
|
if [ -d "$ZORAXY_DIR" ]; then
|
||||||
|
# Sauvegarde : conf/ (règles proxy, settings) mais PAS le binaire (gros)
|
||||||
|
# Les certs Let's Encrypt sont séparément dans /etc/letsencrypt/
|
||||||
|
tar -czf "$ZORAXY_ARCHIVE" \
|
||||||
|
-C "$ZORAXY_DIR" \
|
||||||
|
--exclude="zoraxy" \ # binaire — re-téléchargeable
|
||||||
|
--exclude="*.log" \
|
||||||
|
. 2>/dev/null && \
|
||||||
|
ok "Zoraxy → $(basename $ZORAXY_ARCHIVE) ($(du -sh $ZORAXY_ARCHIVE | cut -f1))" || \
|
||||||
|
err "Échec sauvegarde Zoraxy"
|
||||||
|
else
|
||||||
|
warn "Zoraxy : dossier $ZORAXY_DIR introuvable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sauvegarde des certificats Let's Encrypt séparément
|
||||||
|
LETSENCRYPT_DIR="/etc/letsencrypt"
|
||||||
|
LE_ARCHIVE="$DEST/letsencrypt-$DATE.tar.gz"
|
||||||
|
|
||||||
|
if [ -d "$LETSENCRYPT_DIR" ]; then
|
||||||
|
tar -czf "$LE_ARCHIVE" "$LETSENCRYPT_DIR" 2>/dev/null && \
|
||||||
|
ok "Let's Encrypt → $(basename $LE_ARCHIVE) ($(du -sh $LE_ARCHIVE | cut -f1))" || \
|
||||||
|
warn "Échec sauvegarde Let's Encrypt (fichiers système, peut nécessiter root)"
|
||||||
|
else
|
||||||
|
warn "Let's Encrypt : /etc/letsencrypt introuvable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. CUPS (Imprimantes)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "▶ Sauvegarde CUPS..."
|
||||||
|
|
||||||
|
CUPS_ARCHIVE="$DEST/cups-$DATE.tar.gz"
|
||||||
|
|
||||||
|
CUPS_FILES=(
|
||||||
|
"/etc/cups/printers.conf" # Définitions des imprimantes (noms, URIs, options)
|
||||||
|
"/etc/cups/cupsd.conf" # Config serveur CUPS (accès, ports)
|
||||||
|
"/etc/cups/ppd" # Fichiers PPD (drivers par imprimante)
|
||||||
|
"/etc/cups/classes.conf" # Groupes d'imprimantes (si présent)
|
||||||
|
)
|
||||||
|
|
||||||
|
EXISTING_CUPS=()
|
||||||
|
for f in "${CUPS_FILES[@]}"; do
|
||||||
|
[ -e "$f" ] && EXISTING_CUPS+=("$f") || true
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#EXISTING_CUPS[@]} -gt 0 ]; then
|
||||||
|
tar -czf "$CUPS_ARCHIVE" "${EXISTING_CUPS[@]}" 2>/dev/null && \
|
||||||
|
ok "CUPS → $(basename $CUPS_ARCHIVE) ($(du -sh $CUPS_ARCHIVE | cut -f1))" || \
|
||||||
|
err "Échec sauvegarde CUPS"
|
||||||
|
|
||||||
|
# Exporter aussi la liste des imprimantes en texte lisible
|
||||||
|
PRINTERS_TXT="$DEST/cups-printers-$DATE.txt"
|
||||||
|
echo "# Imprimantes CUPS — $DATE" > "$PRINTERS_TXT"
|
||||||
|
echo "" >> "$PRINTERS_TXT"
|
||||||
|
lpstat -v 2>/dev/null >> "$PRINTERS_TXT" || true
|
||||||
|
echo "" >> "$PRINTERS_TXT"
|
||||||
|
lpstat -p 2>/dev/null >> "$PRINTERS_TXT" || true
|
||||||
|
ok "Liste imprimantes → $(basename $PRINTERS_TXT)"
|
||||||
|
else
|
||||||
|
warn "CUPS : aucun fichier de config trouvé (CUPS installé ?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 4. JH Photomaton (config + DB)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "▶ Sauvegarde JH Photomaton..."
|
||||||
|
|
||||||
|
JH_DIR="/home/pi/jh-photomaton"
|
||||||
|
JH_ARCHIVE="$DEST/jh-photomaton-$DATE.tar.gz"
|
||||||
|
|
||||||
|
if [ -d "$JH_DIR" ]; then
|
||||||
|
tar -czf "$JH_ARCHIVE" \
|
||||||
|
-C "$JH_DIR" \
|
||||||
|
--exclude=".venv" \
|
||||||
|
--exclude="__pycache__" \
|
||||||
|
--exclude="*.pyc" \
|
||||||
|
"config/settings.yaml" \
|
||||||
|
"data/" \
|
||||||
|
2>/dev/null && \
|
||||||
|
ok "JH Photomaton config → $(basename $JH_ARCHIVE)" || \
|
||||||
|
warn "JH Photomaton : sauvegarde partielle"
|
||||||
|
else
|
||||||
|
warn "JH Photomaton : $JH_DIR introuvable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Config photobooth-app
|
||||||
|
PB_CFG="/home/pi/.config/photobooth-app"
|
||||||
|
PB_ARCHIVE="$DEST/photobooth-app-config-$DATE.tar.gz"
|
||||||
|
|
||||||
|
if [ -d "$PB_CFG" ]; then
|
||||||
|
tar -czf "$PB_ARCHIVE" "$PB_CFG" 2>/dev/null && \
|
||||||
|
ok "photobooth-app config → $(basename $PB_ARCHIVE)" || \
|
||||||
|
warn "Échec sauvegarde config photobooth-app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Résumé
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " Archives créées dans : $DEST"
|
||||||
|
ls -lh "$DEST/"*"$DATE"* 2>/dev/null | awk '{print " " $NF " (" $5 ")"}'
|
||||||
|
echo ""
|
||||||
|
if [ $ERRORS -eq 0 ]; then
|
||||||
|
ok "Sauvegarde terminée sans erreur."
|
||||||
|
else
|
||||||
|
err "$ERRORS erreur(s) durant la sauvegarde."
|
||||||
|
fi
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
Reference in New Issue
Block a user