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)
|
||||
async def admin_print(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
|
||||
@@ -1,98 +1,15 @@
|
||||
"""API galerie admin — impression et suppression de photos."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/api/gallery/photos")
|
||||
async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
page_photos = photos[start:start + limit]
|
||||
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
|
||||
return {"photos": page_photos, "total": total, "page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit)}
|
||||
|
||||
|
||||
@router.post("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_print_photo(
|
||||
request: Request,
|
||||
photo_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
):
|
||||
"""Impression directe depuis la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
# Reconstruit le chemin du fichier depuis l'ID
|
||||
from pathlib import Path
|
||||
import re
|
||||
cfg = request.app.state.config
|
||||
|
||||
# photobooth-app identifiant → chemin fichier
|
||||
media_dir = cfg.photobooth.media_dir
|
||||
# Cherche le fichier correspondant
|
||||
filename = _find_file(media_dir, photo_id)
|
||||
if not filename:
|
||||
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
|
||||
if cfg.print.mode == "direct":
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry["id"], copies)
|
||||
await ws.broadcast({"type": "print_result", "result": result})
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
else:
|
||||
led.play("error")
|
||||
return result
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
return {"ok": True, "entry": entry, "mode": cfg.print.mode}
|
||||
|
||||
|
||||
@router.delete("/admin/api/gallery/{photo_id}")
|
||||
async def admin_delete_photo(request: Request, photo_id: str):
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
ok = await pb.delete_media(photo_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
|
||||
return {"ok": ok, "photo_id": photo_id}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
t = item.get("type", item.get("mediaitem_type", "image"))
|
||||
@@ -103,20 +20,179 @@ def _get_id(item: dict) -> str:
|
||||
return str(item.get("id", item.get("filename", item.get("uid", ""))))
|
||||
|
||||
|
||||
def _find_file(media_dir: str, photo_id: str):
|
||||
def _find_file(media_dir: str, photo_id: str) -> Path | None:
|
||||
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
|
||||
from pathlib import Path
|
||||
base = Path(media_dir)
|
||||
if not base.exists():
|
||||
return None
|
||||
|
||||
# L'ID peut être le stem du filename
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
f = base / f"{photo_id}{ext}"
|
||||
if f.exists():
|
||||
return f
|
||||
# Cherche dans les sous-dossiers
|
||||
matches = list(base.rglob(f"{photo_id}{ext}"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def _require_auth(request: Request):
|
||||
return request.session.get("authenticated") is True
|
||||
|
||||
|
||||
# ── Photos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/api/gallery/photos")
|
||||
async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
|
||||
# Récupère toutes les photos
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
page_photos = photos[start:start + limit]
|
||||
|
||||
# Construit les URLs
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["photo_id"] = pid
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
|
||||
# Croise avec la file d'impression en attente (une seule requête SQLite)
|
||||
try:
|
||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
||||
for p in page_photos:
|
||||
pid = p.get("photo_id", "")
|
||||
requests = pending_map.get(pid, [])
|
||||
p["print_requests"] = requests
|
||||
p["print_pending"] = len([r for r in requests if r["status"] == "pending"])
|
||||
p["print_printing"] = len([r for r in requests if r["status"] == "printing"])
|
||||
except Exception as e:
|
||||
logger.warning("Impossible de croiser avec print_queue: %s", e)
|
||||
for p in page_photos:
|
||||
p["print_requests"] = []
|
||||
p["print_pending"] = 0
|
||||
p["print_printing"] = 0
|
||||
|
||||
return {
|
||||
"photos": page_photos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
}
|
||||
|
||||
|
||||
# ── Demandes d'impression ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_print_photo(
|
||||
request: Request,
|
||||
photo_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
immediate: bool = Query(default=False, description="True = imprimer maintenant sans passer par la file"),
|
||||
):
|
||||
"""Ajoute une demande d'impression (ou imprime immédiatement si immediate=true).
|
||||
|
||||
En mode 'validation', la demande est mise en file — l'admin valide via /admin/print.
|
||||
En mode 'direct' ou si immediate=true, l'impression est lancée tout de suite.
|
||||
"""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
cfg = request.app.state.config
|
||||
|
||||
filename = _find_file(cfg.photobooth.media_dir, photo_id)
|
||||
if not filename:
|
||||
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
|
||||
should_print_now = (cfg.print.mode == "direct") or immediate
|
||||
|
||||
if should_print_now:
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry["id"], copies)
|
||||
await ws.broadcast({"type": "print_result", "result": result, "photo_id": photo_id})
|
||||
led.play("finished" if result["success"] else "error")
|
||||
return {**result, "entry_id": entry["id"], "photo_id": photo_id}
|
||||
|
||||
# Mode validation : juste en file
|
||||
await ws.broadcast({
|
||||
"type": "print_request",
|
||||
"entry": entry,
|
||||
"photo_id": photo_id,
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"queued": True,
|
||||
"entry_id": entry["id"],
|
||||
"photo_id": photo_id,
|
||||
"mode": cfg.print.mode,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_cancel_print_requests(request: Request, photo_id: str):
|
||||
"""Annule toutes les demandes d'impression en attente pour une photo."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
printer_svc = request.app.state.printer_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
count = await printer_svc.cancel_by_photo_id(photo_id)
|
||||
if count:
|
||||
await ws.broadcast({"type": "print_cancelled_for_photo", "photo_id": photo_id, "count": count})
|
||||
return {"ok": True, "photo_id": photo_id, "cancelled": count}
|
||||
|
||||
|
||||
@router.get("/admin/api/gallery/print-status")
|
||||
async def admin_print_status(request: Request):
|
||||
"""Retourne le dict {photo_id_stem: pending_count} pour toute la file.
|
||||
|
||||
Utilisé par la galerie pour mettre à jour les badges sans recharger les photos.
|
||||
"""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
printer_svc = request.app.state.printer_service
|
||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
||||
|
||||
# Format compact : {stem: count} pour minimiser la taille de la réponse
|
||||
return {
|
||||
stem: len(entries)
|
||||
for stem, entries in pending_map.items()
|
||||
}
|
||||
|
||||
|
||||
# ── Suppression ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/admin/api/gallery/{photo_id}")
|
||||
async def admin_delete_photo(request: Request, photo_id: str):
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
ok = await pb.delete_media(photo_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
|
||||
return {"ok": ok, "photo_id": photo_id}
|
||||
|
||||
@@ -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
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -15,7 +18,6 @@ _templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "fr
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""Page d'accueil → redirige vers la galerie publique."""
|
||||
return RedirectResponse(url="/gallery")
|
||||
|
||||
|
||||
@@ -23,7 +25,7 @@ async def index(request: Request):
|
||||
async def gallery_page(request: Request):
|
||||
cfg = request.app.state.config
|
||||
if not cfg.gallery.public_enabled:
|
||||
return HTMLResponse("<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})
|
||||
|
||||
|
||||
@@ -33,13 +35,9 @@ async def api_gallery_photos(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos depuis photobooth-app (paginée)."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
|
||||
# Filtre sur les images uniquement
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
@@ -47,7 +45,6 @@ async def api_gallery_photos(
|
||||
end = start + limit
|
||||
page_photos = photos[start:end]
|
||||
|
||||
# Enrichit avec les URLs
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
@@ -64,9 +61,37 @@ async def api_gallery_photos(
|
||||
|
||||
@router.get("/api/gallery/download/{photo_id}")
|
||||
async def download_photo(request: Request, photo_id: str):
|
||||
"""Redirige vers le fichier full-res sur photobooth-app."""
|
||||
"""Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours."""
|
||||
pb = request.app.state.photobooth_service
|
||||
return RedirectResponse(url=pb.media_url(photo_id))
|
||||
cfg = request.app.state.config
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
|
||||
img_url = pb.media_url(photo_id)
|
||||
slug = cfg.event.slug or "photomaton"
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
filename = f"{slug}_By_LSDW_{date_str}.jpg"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.get(img_url)
|
||||
r.raise_for_status()
|
||||
|
||||
if event_svc:
|
||||
asyncio.create_task(event_svc.increment(slug, "downloads"))
|
||||
|
||||
content_type = r.headers.get("content-type", "image/jpeg")
|
||||
return Response(
|
||||
content=r.content,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(r.content)),
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e)
|
||||
return RedirectResponse(url=img_url)
|
||||
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
|
||||
@@ -49,3 +49,52 @@ async def leds_off(request: Request):
|
||||
led = request.app.state.led_service
|
||||
led.play("off")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/leds/effects")
|
||||
async def get_effects(request: Request):
|
||||
"""Retourne la configuration de tous les effets LED."""
|
||||
cfg = request.app.state.config.leds
|
||||
return {
|
||||
name: {
|
||||
"color": effect.get("color", [0, 0, 0]),
|
||||
"flash_duration": effect.get("flash_duration", 0.1),
|
||||
"flashes": effect.get("flashes", 2),
|
||||
"speed": effect.get("speed", 0.05),
|
||||
"duration": effect.get("duration", 2.0),
|
||||
}
|
||||
for name, effect in cfg.effects.items()
|
||||
}
|
||||
|
||||
|
||||
@router.put("/leds/effect/{effect_name}")
|
||||
async def update_effect(
|
||||
request: Request,
|
||||
effect_name: str,
|
||||
r: int = Query(default=None, ge=0, le=255),
|
||||
g: int = Query(default=None, ge=0, le=255),
|
||||
b: int = Query(default=None, ge=0, le=255),
|
||||
flash_duration: float = Query(default=None, ge=0.05, le=2.0),
|
||||
flashes: int = Query(default=None, ge=1, le=5),
|
||||
save: bool = Query(default=True),
|
||||
):
|
||||
"""Met a jour la configuration d'un effet LED (couleur, timings).
|
||||
|
||||
Exemple : PUT /api/leds/effect/capture?r=255&g=200&b=80&flash_duration=0.3&flashes=2
|
||||
"""
|
||||
cfg = request.app.state.config.leds
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
effect_raw = cfg.effects.setdefault(effect_name, {})
|
||||
|
||||
if r is not None and g is not None and b is not None:
|
||||
effect_raw["color"] = [r, g, b]
|
||||
if flash_duration is not None:
|
||||
effect_raw["flash_duration"] = flash_duration
|
||||
if flashes is not None:
|
||||
effect_raw["flashes"] = flashes
|
||||
|
||||
if save:
|
||||
config_svc.save_led_effect(effect_name, effect_raw)
|
||||
|
||||
return {"ok": True, "effect": effect_name, "config": effect_raw}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API gestion de la file d'attente d'impression."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,6 +34,12 @@ async def print_request(
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
|
||||
# Compteur statistiques événement
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "print_requests"))
|
||||
|
||||
# Feedback LED si impression directe
|
||||
if request.app.state.config.print.mode == "direct":
|
||||
led.play("printing")
|
||||
@@ -71,6 +78,10 @@ async def execute_print(
|
||||
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "prints_done"))
|
||||
else:
|
||||
led.play("error")
|
||||
|
||||
@@ -112,3 +123,40 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
|
||||
config_svc.save_print_mode(mode)
|
||||
request.app.state.config.print.mode = mode
|
||||
return {"ok": True, "mode": mode}
|
||||
|
||||
|
||||
# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
|
||||
|
||||
@router.post("/print/printers/{printer_name}/enable")
|
||||
async def printer_enable(request: Request, printer_name: str):
|
||||
"""Active une imprimante CUPS (cupsenable + cupsaccept)."""
|
||||
ok = await request.app.state.printer_service.enable_printer(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "enable"}
|
||||
|
||||
|
||||
@router.post("/print/printers/{printer_name}/disable")
|
||||
async def printer_disable(request: Request, printer_name: str):
|
||||
"""Désactive une imprimante CUPS (cupsdisable)."""
|
||||
ok = await request.app.state.printer_service.disable_printer(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "disable"}
|
||||
|
||||
|
||||
@router.post("/print/printers/{printer_name}/reject")
|
||||
async def printer_reject(request: Request, printer_name: str):
|
||||
"""Refuse les nouveaux jobs sans stopper l'impression en cours."""
|
||||
ok = await request.app.state.printer_service.reject_jobs(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "reject"}
|
||||
|
||||
|
||||
@router.get("/print/printers/{printer_name}/jobs")
|
||||
async def printer_jobs(request: Request, printer_name: str):
|
||||
"""Liste les jobs CUPS en cours pour une imprimante."""
|
||||
jobs = await request.app.state.printer_service.get_cups_jobs(printer_name)
|
||||
return {"printer": printer_name, "jobs": jobs}
|
||||
|
||||
|
||||
@router.delete("/print/printers/jobs/{job_id}")
|
||||
async def cancel_one_job(request: Request, job_id: str):
|
||||
"""Annule un job CUPS précis par son ID."""
|
||||
ok = await request.app.state.printer_service.cancel_cups_job(job_id)
|
||||
return {"ok": ok, "job_id": job_id}
|
||||
|
||||
+111
-1
@@ -1,8 +1,10 @@
|
||||
"""API de surveillance des ressources système."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -50,3 +52,111 @@ async def control_relay(request: Request, state: str = "on"):
|
||||
else:
|
||||
btn.relay_off()
|
||||
return {"ok": True, "relay": state}
|
||||
|
||||
|
||||
@router.get("/system/ui/delete-button")
|
||||
async def get_delete_button(request: Request):
|
||||
"""Retourne l'état actuel du bouton Supprimer de photobooth-app."""
|
||||
visible = request.app.state.config.photobooth.show_delete_button
|
||||
return {"visible": visible}
|
||||
|
||||
|
||||
@router.post("/system/ui/delete-button")
|
||||
async def set_delete_button(request: Request, visible: bool):
|
||||
"""Active ou désactive le bouton Supprimer dans l'UI de review photobooth-app.
|
||||
|
||||
Écrit userdata/private.css en temps réel — effectif à la prochaine capture,
|
||||
sans redémarrage.
|
||||
"""
|
||||
pb = request.app.state.photobooth_service
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
ok = await pb.set_delete_button_visible(visible)
|
||||
if ok:
|
||||
config_svc.save_show_delete_button(visible)
|
||||
|
||||
return {"ok": ok, "visible": visible}
|
||||
|
||||
|
||||
@router.post("/system/screen/refresh")
|
||||
async def screen_refresh(request: Request):
|
||||
"""Envoie F5 à l'écran HDMI connecté (Chromium kiosque Wayland/X11)."""
|
||||
import subprocess, os, asyncio
|
||||
|
||||
# Cherche le display Wayland du user pi (UID 1000)
|
||||
# Essaie wayland-0 puis wayland-1
|
||||
xdg_runtime = "/run/user/1000"
|
||||
cmd_wtype = f"WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5"
|
||||
cmd_x11 = f"DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority xdotool key F5"
|
||||
|
||||
for cmd in [cmd_wtype, cmd_x11]:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
f"su -s /bin/bash pi -c '{cmd}'",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=3)
|
||||
if proc.returncode == 0:
|
||||
logger.info("Screen refresh OK via: %s", cmd.split()[0])
|
||||
return {"ok": True, "method": cmd.split()[0]}
|
||||
except Exception as e:
|
||||
logger.debug("Screen refresh cmd failed (%s): %s", cmd.split()[0], e)
|
||||
|
||||
return JSONResponse({"ok": False, "error": "Aucune méthode disponible (wtype/xdotool)"}, status_code=500)
|
||||
|
||||
|
||||
class ButtonConfigUpdate(BaseModel):
|
||||
double_click_ms: Optional[int] = None
|
||||
long_press_ms: Optional[int] = None
|
||||
debounce_ms: Optional[int] = None
|
||||
max_clicks: Optional[int] = None
|
||||
print_enabled: Optional[bool] = None
|
||||
save: bool = False # True = persiste dans settings.yaml
|
||||
|
||||
|
||||
@router.get("/system/button/config")
|
||||
async def get_button_config(request: Request):
|
||||
"""Retourne la configuration actuelle du bouton (timings)."""
|
||||
cfg = request.app.state.config.button
|
||||
return {
|
||||
"double_click_ms": cfg.double_click_ms,
|
||||
"long_press_ms": cfg.long_press_ms,
|
||||
"debounce_ms": cfg.debounce_ms,
|
||||
"max_clicks": cfg.max_clicks,
|
||||
"print_enabled": cfg.print_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/system/button/config")
|
||||
async def update_button_config(request: Request, payload: ButtonConfigUpdate):
|
||||
"""Met à jour les timings du bouton (à chaud + optionnellement sauvegardé)."""
|
||||
btn = request.app.state.button_service
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
update = {k: v for k, v in payload.model_dump(exclude={"save"}).items() if v is not None}
|
||||
|
||||
# Application immédiate (sans redémarrage)
|
||||
btn.update_timings(
|
||||
double_click_ms=payload.double_click_ms,
|
||||
long_press_ms=payload.long_press_ms,
|
||||
debounce_ms=payload.debounce_ms,
|
||||
max_clicks=payload.max_clicks,
|
||||
)
|
||||
|
||||
# Persistance optionnelle
|
||||
if payload.save and update:
|
||||
config_svc.save_button_config(update)
|
||||
|
||||
cfg = request.app.state.config.button
|
||||
return {
|
||||
"ok": True,
|
||||
"saved": payload.save,
|
||||
"current": {
|
||||
"double_click_ms": cfg.double_click_ms,
|
||||
"long_press_ms": cfg.long_press_ms,
|
||||
"debounce_ms": cfg.debounce_ms,
|
||||
"max_clicks": cfg.max_clicks,
|
||||
"print_enabled": cfg.print_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
+8
-40
@@ -1,16 +1,6 @@
|
||||
"""Webhooks reçus de photobooth-app via plugin_commander.
|
||||
|
||||
photobooth-app envoie un GET sur /api/webhook/photobooth?event_key=XXX&mediaitem_type=image
|
||||
pour chaque événement de capture.
|
||||
|
||||
Événements :
|
||||
counting → countdown en cours (LEDs remplissage vert)
|
||||
capture → flash photo (LEDs flash blanc)
|
||||
captured → photo prise (LEDs violet)
|
||||
finished → traitement terminé (LEDs bleu → retour idle + relay ON)
|
||||
start/stop → démarrage/arrêt photobooth-app
|
||||
"""
|
||||
"""Webhooks recus de photobooth-app via plugin_commander."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
@@ -18,7 +8,6 @@ from fastapi import APIRouter, Request, Query
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Durée par défaut du countdown (en secondes) — peut être overridé par action
|
||||
DEFAULT_COUNTDOWN = 5.0
|
||||
|
||||
|
||||
@@ -33,30 +22,25 @@ async def photobooth_webhook(
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
|
||||
|
||||
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
|
||||
|
||||
match event_key:
|
||||
case "counting":
|
||||
# Countdown en cours — remplissage LED vert
|
||||
# On cherche la durée du countdown dans la config de l'action courante
|
||||
countdown_duration = _get_countdown_duration(request)
|
||||
led.play("countdown", countdown_duration=countdown_duration)
|
||||
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
||||
|
||||
case "capture":
|
||||
# Flash photo
|
||||
led.play("capture")
|
||||
|
||||
case "captured":
|
||||
# Photo prise, traitement en cours
|
||||
led.play("captured")
|
||||
|
||||
case "finished":
|
||||
# Traitement terminé
|
||||
led.play("finished")
|
||||
# Réactive le relay après l'animation "finished" (durée ~2s gérée par le service LED)
|
||||
import asyncio
|
||||
asyncio.create_task(_delayed_relay_on(btn, 2.5))
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "photos_taken"))
|
||||
|
||||
case "start":
|
||||
led.play("idle")
|
||||
@@ -65,28 +49,12 @@ async def photobooth_webhook(
|
||||
led.play("off")
|
||||
|
||||
case _:
|
||||
logger.debug("Événement inconnu: %s", event_key)
|
||||
logger.debug("Evenement inconnu: %s", event_key)
|
||||
|
||||
return {"ok": True, "event": event_key}
|
||||
|
||||
|
||||
async def _delayed_relay_on(btn, delay: float):
|
||||
import asyncio
|
||||
await asyncio.sleep(delay)
|
||||
if btn:
|
||||
btn.relay_on()
|
||||
|
||||
|
||||
def _get_countdown_duration(request: Request) -> float:
|
||||
"""
|
||||
Essaie de récupérer la durée du countdown depuis la config photobooth-app
|
||||
en lisant le dernier index d'action déclenché par le bouton.
|
||||
Retourne la valeur par défaut si indisponible.
|
||||
"""
|
||||
try:
|
||||
btn = request.app.state.button_service
|
||||
# On pourrait stocker le dernier index dans le button_service
|
||||
# Pour l'instant on retourne 5s par défaut
|
||||
return DEFAULT_COUNTDOWN
|
||||
except Exception:
|
||||
return DEFAULT_COUNTDOWN
|
||||
|
||||
@@ -42,6 +42,7 @@ class ButtonService:
|
||||
self._click_count = 0
|
||||
self._press_time: float = 0.0
|
||||
self._click_timer: threading.Timer | None = None
|
||||
self._long_timer: threading.Timer | None = None # ← fix: init explicite
|
||||
self._long_press_fired = False
|
||||
self._relay_enabled = True
|
||||
self._lock = threading.Lock()
|
||||
@@ -110,6 +111,33 @@ class ButtonService:
|
||||
def simulate_long_press(self):
|
||||
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
|
||||
|
||||
def update_timings(
|
||||
self,
|
||||
double_click_ms: int | None = None,
|
||||
long_press_ms: int | None = None,
|
||||
debounce_ms: int | None = None,
|
||||
max_clicks: int | None = None,
|
||||
):
|
||||
"""Met à jour les timings à chaud (sans redémarrage du service)."""
|
||||
if double_click_ms is not None:
|
||||
self._cfg.double_click_ms = double_click_ms
|
||||
if long_press_ms is not None:
|
||||
self._cfg.long_press_ms = long_press_ms
|
||||
if max_clicks is not None:
|
||||
self._cfg.max_clicks = max(1, min(4, max_clicks))
|
||||
if debounce_ms is not None:
|
||||
self._cfg.debounce_ms = debounce_ms
|
||||
if self._button:
|
||||
try:
|
||||
self._button.bounce_time = debounce_ms / 1000
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"Timings bouton mis à jour : double_click=%dms, long_press=%dms, debounce=%dms, max_clicks=%d",
|
||||
self._cfg.double_click_ms, self._cfg.long_press_ms,
|
||||
self._cfg.debounce_ms, self._cfg.max_clicks,
|
||||
)
|
||||
|
||||
# ── Callbacks GPIO ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_pressed(self):
|
||||
|
||||
@@ -28,6 +28,7 @@ class PhotoboothConfig:
|
||||
config_file: str = "/home/pi/.config/photobooth-app/config.json"
|
||||
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
|
||||
userdata_dir: str = "/home/pi/photobooth-data/userdata"
|
||||
show_delete_button: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,6 +90,13 @@ class GalleryConfig:
|
||||
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventConfig:
|
||||
name: str = "Evenement"
|
||||
slug: str = "evenement"
|
||||
started_at: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
app: AppConfig = field(default_factory=AppConfig)
|
||||
@@ -97,6 +105,7 @@ class Config:
|
||||
leds: LEDConfig = field(default_factory=LEDConfig)
|
||||
print: PrintConfig = field(default_factory=PrintConfig)
|
||||
gallery: GalleryConfig = field(default_factory=GalleryConfig)
|
||||
event: EventConfig = field(default_factory=EventConfig)
|
||||
button_actions: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -107,7 +116,7 @@ class ConfigService:
|
||||
|
||||
def load(self) -> Config:
|
||||
if not self.path.exists():
|
||||
logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
|
||||
logger.warning("Config introuvable: %s", self.path)
|
||||
self._config = Config()
|
||||
return self._config
|
||||
|
||||
@@ -143,30 +152,60 @@ class ConfigService:
|
||||
if "gallery" in raw:
|
||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
|
||||
|
||||
if "event" in raw:
|
||||
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
|
||||
|
||||
cfg.button_actions = raw.get("button_actions", {
|
||||
1: {"label": "Photo normale", "photobooth_index": 0},
|
||||
2: {"label": "Photo étoile", "photobooth_index": 1},
|
||||
2: {"label": "Photo etoile", "photobooth_index": 1},
|
||||
3: {"label": "Photo cailloux", "photobooth_index": 2},
|
||||
4: {"label": "Photo soirée", "photobooth_index": 3},
|
||||
4: {"label": "Photo soiree", "photobooth_index": 3},
|
||||
})
|
||||
|
||||
self._config = cfg
|
||||
logger.info("Configuration chargée depuis %s", self.path)
|
||||
logger.info("Configuration chargee depuis %s", self.path)
|
||||
return cfg
|
||||
|
||||
def save_button_actions(self, button_actions: dict):
|
||||
"""Met à jour uniquement la section button_actions dans settings.yaml."""
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
raw["button_actions"] = button_actions
|
||||
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
if self._config:
|
||||
self._config.button_actions = button_actions
|
||||
|
||||
def save_show_delete_button(self, visible: bool):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("photobooth", {})["show_delete_button"] = visible
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.photobooth.show_delete_button = visible
|
||||
|
||||
def save_button_config(self, data: dict):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("button", {}).update(data)
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
for k, v in data.items():
|
||||
if hasattr(self._config.button, k):
|
||||
setattr(self._config.button, k, v)
|
||||
|
||||
def save_event_config(self, name: str, slug: str, started_at: float):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw["event"] = {"name": name, "slug": slug, "started_at": started_at}
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.event.name = name
|
||||
self._config.event.slug = slug
|
||||
self._config.event.started_at = started_at
|
||||
|
||||
def save_print_mode(self, mode: str):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
@@ -181,3 +220,12 @@ class ConfigService:
|
||||
if self._config is None:
|
||||
self.load()
|
||||
return self._config
|
||||
|
||||
def save_led_effect(self, effect_name: str, effect_data: dict):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("leds", {}).setdefault("effects", {})[effect_name] = effect_data
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.leds.effects[effect_name] = effect_data
|
||||
|
||||
@@ -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 mock : log des opérations uniquement (développement)
|
||||
Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem)
|
||||
Mode mock : log uniquement (developpement)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
@@ -16,13 +17,12 @@ from backend.services.config_service import Config, LEDConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color
|
||||
HAS_WS281X = True
|
||||
except ImportError:
|
||||
HAS_WS281X = False
|
||||
logger.warning("rpi_ws281x non disponible — mode mock LED activé")
|
||||
logger.warning("rpi_ws281x non disponible -- mode mock LED")
|
||||
|
||||
class WS_Color: # type: ignore
|
||||
def __init__(self, r: int, g: int, b: int):
|
||||
@@ -31,7 +31,7 @@ except ImportError:
|
||||
return f"Color({self.r},{self.g},{self.b})"
|
||||
|
||||
class PixelStrip: # type: ignore
|
||||
def __init__(self, *args, **kwargs): pass
|
||||
def __init__(self, *a, **kw): pass
|
||||
def begin(self): pass
|
||||
def show(self): pass
|
||||
def setPixelColor(self, i, c): pass
|
||||
@@ -40,16 +40,10 @@ except ImportError:
|
||||
|
||||
|
||||
def _color(rgb: list[int]) -> WS_Color:
|
||||
return WS_Color(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def _lerp(a: int, b: int, t: float) -> int:
|
||||
return int(a + (b - a) * t)
|
||||
return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
||||
|
||||
|
||||
class LEDService:
|
||||
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
|
||||
|
||||
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
||||
"printing", "error", "disabled", "off")
|
||||
|
||||
@@ -74,16 +68,16 @@ class LEDService:
|
||||
self._cfg.pin,
|
||||
self._cfg.freq_hz,
|
||||
self._cfg.dma,
|
||||
False, # invert
|
||||
False,
|
||||
self._cfg.brightness,
|
||||
0, # channel
|
||||
0,
|
||||
)
|
||||
try:
|
||||
self._strip.begin()
|
||||
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
logger.info("Strip WS2812b initialise (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
except Exception as e:
|
||||
logger.error("Erreur init strip LED: %s", e)
|
||||
self._strip = PixelStrip() # fallback mock
|
||||
self._strip = PixelStrip()
|
||||
else:
|
||||
self._strip = PixelStrip()
|
||||
|
||||
@@ -104,10 +98,9 @@ class LEDService:
|
||||
def set_on_change(self, cb: Callable):
|
||||
self._on_change_cb = cb
|
||||
|
||||
# ── Commandes publiques (thread-safe) ────────────────────────────────────
|
||||
# ── Commandes publiques ───────────────────────────────────────────────────
|
||||
|
||||
def play(self, effect: str, countdown_duration: float = 5.0):
|
||||
"""Joue un effet nommé. Thread-safe."""
|
||||
with self._lock:
|
||||
self._current_effect = effect
|
||||
self._countdown_duration = countdown_duration
|
||||
@@ -116,22 +109,21 @@ class LEDService:
|
||||
self._notify_change(effect)
|
||||
|
||||
def set_color(self, r: int, g: int, b: int):
|
||||
"""Couleur fixe immédiate."""
|
||||
self._fill(WS_Color(r, g, b))
|
||||
|
||||
# ── Thread principal ──────────────────────────────────────────────────────
|
||||
|
||||
def _run(self):
|
||||
effect_func = {
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
}
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
@@ -143,19 +135,16 @@ class LEDService:
|
||||
fn = effect_func.get(current, self._effect_idle)
|
||||
|
||||
try:
|
||||
if current in ("countdown",):
|
||||
if current == "countdown":
|
||||
fn(cd_dur)
|
||||
else:
|
||||
fn()
|
||||
except Exception as e:
|
||||
logger.error("Erreur effet LED '%s': %s", current, e)
|
||||
|
||||
# Si l'effet s'est terminé naturellement (ex: finished → retour idle)
|
||||
# on vérifie si un nouveau cmd est arrivé
|
||||
if not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
with self._lock:
|
||||
if self._current_effect == current:
|
||||
# Retour automatique à idle après effets ponctuels
|
||||
if current in ("capture", "captured", "finished", "error"):
|
||||
self._current_effect = "idle"
|
||||
self._cmd_event.wait(timeout=0.1)
|
||||
@@ -163,27 +152,23 @@ class LEDService:
|
||||
# ── Effets ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _effect_idle(self):
|
||||
"""Respiration bleue douce."""
|
||||
cfg = self._cfg.get_effect("idle")
|
||||
c = cfg.color
|
||||
speed = cfg.speed
|
||||
n = self._cfg.count
|
||||
|
||||
step = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
|
||||
t = (1 + math.sin(step * 0.1)) / 2
|
||||
brightness = max(0.05, t)
|
||||
color = WS_Color(
|
||||
self._fill(WS_Color(
|
||||
int(c[0] * brightness),
|
||||
int(c[1] * brightness),
|
||||
int(c[2] * brightness),
|
||||
)
|
||||
self._fill(color)
|
||||
))
|
||||
time.sleep(speed)
|
||||
step += 1
|
||||
|
||||
def _effect_countdown(self, duration: float = 5.0):
|
||||
"""Remplissage progressif vert LED par LED."""
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
@@ -191,55 +176,68 @@ class LEDService:
|
||||
color = WS_Color(c[0], c[1], c[2])
|
||||
|
||||
self._fill(blank)
|
||||
|
||||
t_start = time.time()
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
elapsed = time.time() - t_start
|
||||
ratio = min(elapsed / duration, 1.0)
|
||||
leds_on = int(ratio * n)
|
||||
|
||||
for i in range(n):
|
||||
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
||||
self._strip.show()
|
||||
|
||||
if ratio >= 1.0:
|
||||
break
|
||||
time.sleep(0.04)
|
||||
|
||||
def _effect_capture(self):
|
||||
"""Flash blanc."""
|
||||
"""Flash photo.
|
||||
|
||||
Utilise la couleur configuree dans settings.yaml > leds > effects > capture.
|
||||
Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage,
|
||||
puis la restaure a la valeur normale.
|
||||
|
||||
Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges.
|
||||
Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255].
|
||||
"""
|
||||
cfg = self._cfg.get_effect("capture")
|
||||
white = WS_Color(255, 255, 255)
|
||||
flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(white)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
# Boost luminosite max pendant le flash
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(255)
|
||||
|
||||
try:
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(flash_color)
|
||||
time.sleep(cfg.flash_duration)
|
||||
self._fill(off)
|
||||
# Pause inter-flash plus courte que le flash lui-meme
|
||||
if _ < cfg.flashes - 1:
|
||||
time.sleep(cfg.flash_duration * 0.4)
|
||||
finally:
|
||||
# Toujours restaurer la luminosite normale meme en cas d'erreur
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(self._cfg.brightness)
|
||||
self._fill(off)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
def _effect_solid(self):
|
||||
"""Couleur pleine selon l'effet courant."""
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cfg = self._cfg.get_effect(current)
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
# Attente jusqu'à prochain cmd
|
||||
self._cmd_event.wait()
|
||||
|
||||
def _effect_finished(self):
|
||||
"""Bleu fixe pendant duration secondes, puis retour idle."""
|
||||
cfg = self._cfg.get_effect("finished")
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
self._cmd_event.wait(timeout=cfg.duration)
|
||||
|
||||
def _effect_spin(self):
|
||||
"""Rotation d'une traînée de LEDs."""
|
||||
cfg = self._cfg.get_effect("printing")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
speed = cfg.speed
|
||||
tail = 6
|
||||
|
||||
pos = 0
|
||||
@@ -248,22 +246,18 @@ class LEDService:
|
||||
dist = (i - pos) % n
|
||||
if dist < tail:
|
||||
factor = (tail - dist) / tail
|
||||
self._strip.setPixelColor(
|
||||
i,
|
||||
WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
),
|
||||
)
|
||||
self._strip.setPixelColor(i, WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
))
|
||||
else:
|
||||
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
||||
self._strip.show()
|
||||
pos = (pos + 1) % n
|
||||
time.sleep(speed)
|
||||
time.sleep(cfg.speed)
|
||||
|
||||
def _effect_error(self):
|
||||
"""Flash rouge."""
|
||||
cfg = self._cfg.get_effect("error")
|
||||
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
|
||||
@@ -125,5 +125,42 @@ class PhotoboothService:
|
||||
results.append(str(f))
|
||||
return sorted(results)
|
||||
|
||||
# ── UI / private.css ──────────────────────────────────────────────────────
|
||||
|
||||
_CSS_HIDE_DELETE = """\
|
||||
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
|
||||
/* Cacher le bouton Supprimer sur l'écran de review après capture */
|
||||
.action-button-delete {
|
||||
display: none !important;
|
||||
}
|
||||
"""
|
||||
|
||||
_CSS_SHOW_DELETE = """\
|
||||
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
|
||||
/* Bouton Supprimer visible (activé depuis le dashboard JH Photomaton) */
|
||||
/* .action-button-delete { display: none !important; } */
|
||||
"""
|
||||
|
||||
async def set_delete_button_visible(self, visible: bool) -> bool:
|
||||
"""Écrit private.css dans userdata pour afficher ou cacher le bouton Supprimer.
|
||||
|
||||
photobooth-app charge automatiquement userdata/private.css à chaque requête
|
||||
— aucun redémarrage nécessaire, effectif dès la prochaine capture.
|
||||
"""
|
||||
css_path = Path(self._cfg.userdata_dir) / "private.css"
|
||||
try:
|
||||
css_content = self._CSS_SHOW_DELETE if visible else self._CSS_HIDE_DELETE
|
||||
css_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
css_path.write_text(css_content, encoding="utf-8")
|
||||
logger.info("private.css mis à jour : bouton delete %s", "visible" if visible else "caché")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Impossible d'écrire private.css : %s", e)
|
||||
return False
|
||||
|
||||
async def get_delete_button_visible(self) -> bool:
|
||||
"""Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
|
||||
return self._cfg.show_delete_button
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -100,6 +100,35 @@ class PrinterService:
|
||||
async def get_pending(self) -> list[dict]:
|
||||
return await self.get_queue("pending")
|
||||
|
||||
async def get_pending_by_photo_id(self) -> dict[str, list]:
|
||||
"""Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives.
|
||||
|
||||
Permet à la galerie admin de savoir quelles photos ont une demande en attente
|
||||
sans modifier le schéma SQLite — on match par stem du filename.
|
||||
"""
|
||||
rows = await self.get_queue("pending")
|
||||
# Aussi inclure celles "printing" (en cours d'impression)
|
||||
rows += await self.get_queue("printing")
|
||||
|
||||
result: dict[str, list] = {}
|
||||
for r in rows:
|
||||
from pathlib import Path
|
||||
stem = Path(r["filename"]).stem
|
||||
result.setdefault(stem, []).append(r)
|
||||
return result
|
||||
|
||||
async def cancel_by_photo_id(self, photo_stem: str) -> int:
|
||||
"""Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé."""
|
||||
pending = await self.get_queue("pending")
|
||||
from pathlib import Path
|
||||
cancelled = 0
|
||||
for r in pending:
|
||||
if Path(r["filename"]).stem == photo_stem:
|
||||
ok = await self.cancel(r["id"])
|
||||
if ok:
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
async def cancel(self, entry_id: str) -> bool:
|
||||
async with self._lock:
|
||||
cursor = await self._db.execute(
|
||||
@@ -186,48 +215,105 @@ class PrinterService:
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _printer_name(self, p) -> str:
|
||||
"""Accepte une entrée printers qui soit un str ou un dict."""
|
||||
return p["name"] if isinstance(p, dict) else str(p)
|
||||
|
||||
def _printer_label(self, p) -> str:
|
||||
return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p)
|
||||
|
||||
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
|
||||
|
||||
async def get_printers_status(self) -> list[dict]:
|
||||
"""Retourne le statut des imprimantes CUPS configurées."""
|
||||
"""Retourne le statut détaillé des imprimantes CUPS configurées."""
|
||||
statuses = []
|
||||
for p in self._cfg.printers:
|
||||
status = await asyncio.to_thread(self._get_printer_status, p["name"])
|
||||
name = self._printer_name(p)
|
||||
status = await asyncio.to_thread(self._get_printer_status, name)
|
||||
statuses.append({
|
||||
"name": p["name"],
|
||||
"label": p["label"],
|
||||
"name": name,
|
||||
"label": self._printer_label(p),
|
||||
**status,
|
||||
})
|
||||
return statuses
|
||||
|
||||
def _get_printer_status(self, printer_name: str) -> dict:
|
||||
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# État de l'imprimante
|
||||
r_state = subprocess.run(
|
||||
["lpstat", "-p", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
output = result.stdout.lower()
|
||||
if "idle" in output:
|
||||
out = r_state.stdout.lower()
|
||||
|
||||
if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower():
|
||||
return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0}
|
||||
|
||||
if "idle" in out:
|
||||
state = "idle"
|
||||
elif "printing" in output or "processing" in output:
|
||||
elif "printing" in out or "processing" in out:
|
||||
state = "printing"
|
||||
elif "disabled" in output:
|
||||
elif "stopped" in out or "disabled" in out:
|
||||
state = "disabled"
|
||||
elif "not found" in output or result.returncode != 0:
|
||||
state = "offline"
|
||||
else:
|
||||
state = "unknown"
|
||||
|
||||
# Compte les jobs en attente
|
||||
jobs_result = subprocess.run(
|
||||
["lpstat", "-o", printer_name],
|
||||
# Est-ce que l'imprimante accepte les nouveaux jobs ?
|
||||
r_accept = subprocess.run(
|
||||
["lpstat", "-a", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l])
|
||||
accepting = "accepting" in r_accept.stdout.lower()
|
||||
|
||||
return {"state": state, "jobs": jobs}
|
||||
# Liste des jobs CUPS en cours
|
||||
jobs = self._get_cups_jobs(printer_name)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"accepting": accepting,
|
||||
"jobs": jobs,
|
||||
"jobs_count": len(jobs),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"state": "error", "jobs": 0, "error": str(e)}
|
||||
return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
|
||||
|
||||
def _get_cups_jobs(self, printer_name: str) -> list[dict]:
|
||||
"""Retourne la liste des jobs CUPS en cours pour une imprimante."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["lpstat", "-o", printer_name, "-l"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
jobs = []
|
||||
current = {}
|
||||
for line in r.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(printer_name):
|
||||
if current:
|
||||
jobs.append(current)
|
||||
parts = line.split()
|
||||
current = {
|
||||
"id": parts[0] if parts else "",
|
||||
"user": parts[1] if len(parts) > 1 else "",
|
||||
"size": parts[3] if len(parts) > 3 else "",
|
||||
"date": " ".join(parts[4:7]) if len(parts) > 6 else "",
|
||||
"details": [],
|
||||
}
|
||||
elif current and ":" in line:
|
||||
current.setdefault("details", []).append(line)
|
||||
if current:
|
||||
jobs.append(current)
|
||||
return jobs
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_cups_jobs(self, printer_name: str) -> list[dict]:
|
||||
return await asyncio.to_thread(self._get_cups_jobs, printer_name)
|
||||
|
||||
async def cancel_cups_jobs(self, printer_name: str) -> bool:
|
||||
"""Annule tous les jobs CUPS pour une imprimante."""
|
||||
@@ -240,3 +326,43 @@ class PrinterService:
|
||||
except Exception as e:
|
||||
logger.error("Erreur cancel CUPS: %s", e)
|
||||
return False
|
||||
|
||||
async def cancel_cups_job(self, job_id: str) -> bool:
|
||||
"""Annule un job CUPS précis."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cancel", job_id],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur cancel CUPS job %s: %s", job_id, e)
|
||||
return False
|
||||
|
||||
async def enable_printer(self, printer_name: str) -> bool:
|
||||
"""Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs."""
|
||||
try:
|
||||
r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10)
|
||||
r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10)
|
||||
return r1.returncode == 0 and r2.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur enable printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
async def disable_printer(self, printer_name: str) -> bool:
|
||||
"""Désactive l'imprimante CUPS (cupsdisable)."""
|
||||
try:
|
||||
result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur disable printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
async def reject_jobs(self, printer_name: str) -> bool:
|
||||
"""Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours."""
|
||||
try:
|
||||
result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur reject printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
+39
-18
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# JH Photomaton — Configuration principale
|
||||
# JH Photomaton -- Configuration principale
|
||||
# Association Les Sapins Du Web (LSDW)
|
||||
# ============================================================
|
||||
|
||||
@@ -11,30 +11,37 @@ app:
|
||||
secret_key: "photomaton-jh-2026-secret-change-me"
|
||||
admin_password: "PhotoBooth2026!"
|
||||
|
||||
# --- Intégration photobooth-app ---
|
||||
# --- Integration photobooth-app ---
|
||||
photobooth:
|
||||
base_url: "http://localhost:8083"
|
||||
base_url: "http://localhost:8000"
|
||||
data_dir: "/home/pi/photobooth-data"
|
||||
config_file: "/home/pi/.config/photobooth-app/config.json"
|
||||
media_dir: "/home/pi/photobooth-data/media/processed_full"
|
||||
userdata_dir: "/home/pi/photobooth-data/userdata"
|
||||
show_delete_button: false
|
||||
|
||||
# --- Evenement en cours ---
|
||||
event:
|
||||
name: "Evenement"
|
||||
slug: "evenement"
|
||||
started_at: 0
|
||||
|
||||
# --- Bouton physique ---
|
||||
button:
|
||||
pin: 23 # GPIO BCM (input, pull-up interne)
|
||||
relay_pin: 12 # GPIO BCM (output, relay ON/OFF bouton 12V)
|
||||
pin: 23
|
||||
relay_pin: 12
|
||||
debounce_ms: 50
|
||||
double_click_ms: 400 # Délai max entre clics pour multi-clic
|
||||
long_press_ms: 1500 # Durée min long appui
|
||||
max_clicks: 4 # Nombre max de clics reconnus
|
||||
long_press_action: "print_last" # print_last | none
|
||||
double_click_ms: 400
|
||||
long_press_ms: 1500
|
||||
max_clicks: 4
|
||||
long_press_action: "print_last"
|
||||
print_enabled: true
|
||||
|
||||
# --- Anneau LED WS2812b ---
|
||||
leds:
|
||||
pin: 18 # GPIO BCM (PWM hardware, pin physique 12)
|
||||
count: 35 # Nombre de LEDs dans l'anneau
|
||||
brightness: 180 # 0-255
|
||||
pin: 18
|
||||
count: 35
|
||||
brightness: 180 # Luminosite normale (0-255)
|
||||
freq_hz: 800000
|
||||
dma: 10
|
||||
strip_type: "WS2812"
|
||||
@@ -43,36 +50,51 @@ leds:
|
||||
color: [0, 30, 80]
|
||||
mode: "breathe"
|
||||
speed: 0.025
|
||||
|
||||
countdown:
|
||||
color: [0, 200, 80]
|
||||
mode: "fill_progressive"
|
||||
|
||||
capture:
|
||||
color: [255, 255, 255]
|
||||
# BLANC CHAUD pour photo -- les WS2812b ont une LED bleue tres efficace.
|
||||
# [255,255,255] donne un rendu tres bleu sur les photos.
|
||||
# Reduisez le bleu (3eme valeur) et augmentez le rouge pour corriger.
|
||||
# Exemples :
|
||||
# [255, 200, 80] -> blanc chaud (recommande pour debuter)
|
||||
# [255, 180, 40] -> blanc tres chaud / jaune
|
||||
# [255, 220, 120] -> blanc neutre legerement chaud
|
||||
color: [255, 200, 80]
|
||||
mode: "flash"
|
||||
flashes: 2
|
||||
flash_duration: 0.1
|
||||
# Duree en secondes de chaque flash (plus long = plus de lumiere)
|
||||
flash_duration: 0.30
|
||||
|
||||
captured:
|
||||
color: [150, 0, 200]
|
||||
mode: "solid"
|
||||
|
||||
finished:
|
||||
color: [0, 100, 255]
|
||||
mode: "solid"
|
||||
duration: 2.0
|
||||
|
||||
printing:
|
||||
color: [0, 120, 255]
|
||||
mode: "spin"
|
||||
speed: 0.05
|
||||
|
||||
error:
|
||||
color: [220, 0, 0]
|
||||
mode: "flash"
|
||||
flashes: 4
|
||||
|
||||
disabled:
|
||||
color: [60, 0, 0]
|
||||
mode: "solid"
|
||||
|
||||
# --- Impression ---
|
||||
print:
|
||||
mode: "validation" # direct | gallery | validation
|
||||
mode: "validation"
|
||||
script_path: "/home/pi/photobooth-data/script/script_print.sh"
|
||||
default_copies: 1
|
||||
printers:
|
||||
@@ -88,9 +110,8 @@ gallery:
|
||||
qr_base_url: "https://photomaton.lessapinsduweb.com"
|
||||
|
||||
# --- Mapping clics bouton -> actions photobooth-app ---
|
||||
# n clics -> photobooth_index dans la liste actions.image de config.json
|
||||
button_actions:
|
||||
1: { label: "Photo normale", photobooth_index: 0 }
|
||||
2: { label: "Photo étoile", photobooth_index: 1 }
|
||||
2: { label: "Photo etoile", photobooth_index: 1 }
|
||||
3: { label: "Photo cailloux", photobooth_index: 2 }
|
||||
4: { label: "Photo soirée", photobooth_index: 3 }
|
||||
4: { label: "Photo soiree", photobooth_index: 3 }
|
||||
|
||||
@@ -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/print">Impression</a>
|
||||
<a href="/admin/actions">Actions</a>
|
||||
<a href="/admin/settings">Réglages</a>
|
||||
<a href="/gallery">Galerie publique</a>
|
||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||
{% endblock %}
|
||||
@@ -53,6 +54,28 @@
|
||||
</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">
|
||||
<!-- Contrôle LED -->
|
||||
<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 ──────────────────────────────────────────────────────────────────────
|
||||
loadQueue();
|
||||
loadEvent();
|
||||
setInterval(loadQueue, 15000);
|
||||
setInterval(loadEvent, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,45 +6,199 @@
|
||||
<a href="/admin/gallery" class="active">Galerie admin</a>
|
||||
<a href="/admin/print">Impression</a>
|
||||
<a href="/admin/actions">Actions</a>
|
||||
<a href="/admin/settings">Réglages</a>
|
||||
<a href="/gallery">Galerie publique</a>
|
||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||
{% 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 %}
|
||||
<div class="container">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="page-title" style="margin:0">Galerie — Administration</h1>
|
||||
<div class="flex gap-1 items-center">
|
||||
<div class="gallery-wrap">
|
||||
|
||||
<div class="gallery-header">
|
||||
<div class="gallery-title">🖼 Galerie — Administration</div>
|
||||
<div class="gallery-controls">
|
||||
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
|
||||
<div class="flex gap-1">
|
||||
<input type="number" id="copies-input" min="1" max="3" value="1" class="form-control" style="width:70px" title="Copies">
|
||||
<button class="btn btn-ghost btn-sm" onclick="refreshPhotos()">↻ Actualiser</button>
|
||||
<div class="copies-wrap">
|
||||
<label for="copies-input">Copies :</label>
|
||||
<input type="number" id="copies-input" min="1" max="3" value="1">
|
||||
</div>
|
||||
<button class="pg-btn" onclick="refreshPhotos()">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres / navigation pages -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
|
||||
<span class="text-sm text-muted">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
|
||||
<button class="btn btn-ghost btn-sm" id="next-btn" onclick="changePage(1)">Suiv. →</button>
|
||||
<!-- Filtres -->
|
||||
<div class="gallery-filters">
|
||||
<button class="filter-btn active" onclick="setFilter('all', this)">Toutes</button>
|
||||
<button class="filter-btn" onclick="setFilter('pending', this)">
|
||||
🖨 À 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 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 class="pagination" style="margin-top:.5rem">
|
||||
<button class="pg-btn" id="prev-btn2" onclick="changePage(-1)" disabled>← Préc.</button>
|
||||
<span class="pg-info">Page <span id="page-cur2">1</span> / <span id="page-total2">1</span></span>
|
||||
<button class="pg-btn" id="next-btn2" onclick="changePage(1)">Suiv. →</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Lightbox admin -->
|
||||
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
|
||||
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
||||
<img id="lightbox-img" src="" alt="">
|
||||
<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>
|
||||
<!-- ── 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="text-sm text-muted" id="lb-filename"></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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -53,99 +207,311 @@
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentPhotoId = null;
|
||||
let currentPhotoData = null;
|
||||
let allPhotos = [];
|
||||
let filterMode = 'all';
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Chargement photos
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
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 {
|
||||
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
|
||||
const { photos, total, pages } = data;
|
||||
|
||||
allPhotos = data.photos || [];
|
||||
currentPage = page;
|
||||
totalPages = pages;
|
||||
totalPages = data.pages || 1;
|
||||
|
||||
document.getElementById('photo-count').textContent = `${total} photo(s)`;
|
||||
document.getElementById('page-cur').textContent = page;
|
||||
document.getElementById('page-total').textContent = pages;
|
||||
document.getElementById('prev-btn').disabled = page <= 1;
|
||||
document.getElementById('next-btn').disabled = page >= pages;
|
||||
updatePagination();
|
||||
document.getElementById('photo-count').textContent = `${data.total} photo(s)`;
|
||||
|
||||
const grid = document.getElementById('photo-grid');
|
||||
if (!photos.length) {
|
||||
grid.innerHTML = '<div class="empty-state"><div class="icon">📷</div>Aucune photo</div>';
|
||||
return;
|
||||
}
|
||||
// Compteur de demandes en attente
|
||||
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
|
||||
document.getElementById('pending-count').textContent = pendingTotal;
|
||||
|
||||
grid.innerHTML = photos.map(p => {
|
||||
const pid = p.id || p.filename || p.uid || '';
|
||||
return `
|
||||
<div class="photo-card" onclick="openLightbox('${pid}', '${p.full_url}', '${p.thumb_url}')">
|
||||
<img src="${p.thumb_url}" loading="lazy" alt="">
|
||||
<div class="photo-card-actions">
|
||||
<button class="btn btn-success btn-sm" onclick="event.stopPropagation();printPhoto('${pid}')">🖨</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
|
||||
<a class="btn btn-ghost btn-sm" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
renderGrid();
|
||||
} catch(e) {
|
||||
document.getElementById('photo-grid').innerHTML = '<div class="empty-state"><div class="icon">❌</div>Erreur de chargement</div>';
|
||||
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(delta) {
|
||||
loadPhotos(currentPage + delta);
|
||||
function renderGrid() {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
grid.innerHTML = photos.map(p => {
|
||||
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 `
|
||||
<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="">
|
||||
${badge}
|
||||
<div class="card-overlay">
|
||||
${hasPending
|
||||
? `<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>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshPhotos() { loadPhotos(currentPage); }
|
||||
function setFilter(mode, btn) {
|
||||
filterMode = mode;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 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');
|
||||
}
|
||||
|
||||
function closeLightbox(e) {
|
||||
if (e && e.target !== document.getElementById('lightbox') && e.type !== 'click') return;
|
||||
document.getElementById('lightbox').classList.remove('open');
|
||||
currentPhotoId = null;
|
||||
function updateLightboxPrintStatus(photo) {
|
||||
const statusBlock = document.getElementById('lb-print-status');
|
||||
const queueList = document.getElementById('lb-queue-list');
|
||||
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(); });
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
async function printPhoto(pid) {
|
||||
const copies = parseInt(document.getElementById('copies-input').value) || 1;
|
||||
if (!confirm(`Imprimer ${copies} copie(s) ?`)) return;
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Actions impression
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
function getCopies() {
|
||||
return parseInt(document.getElementById('copies-input').value) || 1;
|
||||
}
|
||||
|
||||
async function lbAddToQueue() {
|
||||
const copies = getCopies();
|
||||
try {
|
||||
const r = await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}`);
|
||||
if (r.success) showToast('✅ Impression lancée sur ' + r.printer, 'success');
|
||||
else if (r.ok) showToast('📋 Demande ajoutée à la file', 'info');
|
||||
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
|
||||
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, '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');
|
||||
} 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) {
|
||||
if (!confirm('Supprimer cette photo définitivement ?')) return;
|
||||
if (!confirm('Supprimer cette photo ?')) return;
|
||||
try {
|
||||
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
|
||||
showToast(r.ok ? '🗑 Photo supprimée' : '❌ Erreur suppression', r.ok ? 'success' : 'error');
|
||||
if (r.ok) {
|
||||
document.getElementById('lightbox').classList.remove('open');
|
||||
loadPhotos(currentPage);
|
||||
}
|
||||
} catch(e) { showToast('Erreur suppression', 'error'); }
|
||||
if (r.ok) { showToast('🗑 Supprimée', 'success'); loadPhotos(currentPage); }
|
||||
} catch(e) { showToast('Erreur', 'error'); }
|
||||
}
|
||||
|
||||
function onWsMessage(msg) {
|
||||
if (msg.type === 'photo_deleted') loadPhotos(currentPage);
|
||||
if (msg.type === 'print_result') showToast(msg.result.success ? '✅ Impression OK' : '❌ ' + msg.result.error, msg.result.success ? 'success' : 'error');
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
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);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,106 +6,270 @@
|
||||
<a href="/admin/gallery">Galerie admin</a>
|
||||
<a href="/admin/print" class="active">Impression</a>
|
||||
<a href="/admin/actions">Actions</a>
|
||||
<a href="/admin/settings">Réglages</a>
|
||||
<a href="/gallery">Galerie publique</a>
|
||||
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1 class="page-title">Gestion de l'impression</h1>
|
||||
{% block head %}
|
||||
<style>
|
||||
.print-grid { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
<!-- Imprimantes -->
|
||||
<div class="section">
|
||||
<div class="card-title">Imprimantes CUPS</div>
|
||||
<div class="card">
|
||||
<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 %}
|
||||
<tr>
|
||||
<td class="font-bold">{{ p.label }}</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if p.state == 'idle' %}badge-success
|
||||
{% 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>
|
||||
</td>
|
||||
<td>{{ p.jobs }}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" onclick="cancelCupsJobs('{{ p.name }}')">✕ Vider file</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
/* ── 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 %}
|
||||
<div class="print-grid">
|
||||
|
||||
<!-- ── IMPRIMANTES ─────────────────────────────────────────────────────── -->
|
||||
<div>
|
||||
<div class="section-title">🖨 Imprimantes CUPS</div>
|
||||
<div class="printers-row" id="printers-container">
|
||||
{% for p in printers %}
|
||||
<div class="printer-card state-{{ p.state }}" id="pc-{{ p.name }}">
|
||||
|
||||
<div class="printer-head">
|
||||
<span class="printer-icon">
|
||||
{% if p.state == 'printing' %}🔵{% elif p.state == 'idle' %}🟢{% elif p.state == 'disabled' %}🟠{% else %}🔴{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
<div class="printer-name">{{ p.label or p.name }}</div>
|
||||
<div class="printer-sublabel">{{ p.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
{% else %}
|
||||
<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>
|
||||
|
||||
<!-- Mode impression -->
|
||||
<div class="section">
|
||||
<div class="card-title">Mode d'impression</div>
|
||||
<div class="card">
|
||||
<div class="flex gap-1 mb-1">
|
||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
||||
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}> Direct (impression automatique)
|
||||
<!-- ── MODE D'IMPRESSION ──────────────────────────────────────────────── -->
|
||||
<div>
|
||||
<div class="section-title">⚙️ Mode d'impression</div>
|
||||
<div class="mode-card">
|
||||
<div class="mode-options">
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}>
|
||||
<label>🚀 Direct (auto)</label>
|
||||
</label>
|
||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
||||
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}> Validation (admin confirme)
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}>
|
||||
<label>✋ Validation admin</label>
|
||||
</label>
|
||||
<label class="flex items-center gap-1" style="cursor:pointer">
|
||||
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}> Galerie (file uniquement)
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}>
|
||||
<label>🖼 File galerie</label>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
<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.
|
||||
</p>
|
||||
<div class="mode-hint">
|
||||
<b>Direct</b> : chaque demande est imprimée immédiatement (load-balancing automatique entre les Selphy).
|
||||
<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>
|
||||
|
||||
<!-- File d'attente -->
|
||||
<div class="section">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="card-title" style="margin:0">File d'attente (<span id="pending-count">{{ queue|length }}</span>)</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="refreshQueue()">↻ Actualiser</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<!-- ── FILE D'ATTENTE ─────────────────────────────────────────────────── -->
|
||||
<div>
|
||||
<div class="queue-card">
|
||||
<div class="queue-header">
|
||||
<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 id="queue-container">
|
||||
{% 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 %}
|
||||
<table class="table" id="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>
|
||||
<table class="queue-table">
|
||||
<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">
|
||||
{% for q in queue %}
|
||||
<tr id="row-{{ q.id }}">
|
||||
<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>
|
||||
<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>
|
||||
<span class="badge
|
||||
{% if q.status == 'pending' %}badge-warning
|
||||
{% elif q.status == 'done' %}badge-success
|
||||
{% elif q.status == 'printing' %}badge-info
|
||||
{% else %}badge-error{% endif %}
|
||||
">{{ q.status }}</span>
|
||||
{% elif q.status == 'cancelled' %}badge-error
|
||||
{% 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 class="text-xs text-muted">{{ q.requested_at|int }}</td>
|
||||
<td>
|
||||
<td style="font-size:.78rem;color:var(--text-muted)">
|
||||
{{ 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' %}
|
||||
<button class="btn btn-success btn-sm" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="cancelPrint('{{ q.id }}')">✕</button>
|
||||
{% elif q.status == 'done' %}
|
||||
<span class="text-sm text-muted">{{ q.printer or '—' }}</span>
|
||||
<button class="btn-sm btn-print" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
|
||||
<button class="btn-sm btn-cancel" onclick="cancelPrint('{{ q.id }}')">✕</button>
|
||||
{% elif q.status == 'error' %}
|
||||
<span style="font-size:.75rem;color:#e05050">{{ q.error_msg or 'Erreur' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -116,6 +280,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -126,31 +291,83 @@ document.querySelectorAll('input[name="print-mode"]').forEach(r => {
|
||||
r.addEventListener('change', async (e) => {
|
||||
try {
|
||||
await api('POST', `/api/print/mode?mode=${e.target.value}`);
|
||||
showToast(`Mode: ${e.target.value}`, 'success');
|
||||
} catch(e) { showToast('Erreur', 'error'); }
|
||||
showToast(`Mode d'impression : ${e.target.value}`, 'success');
|
||||
} 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 ─────────────────────────────────────────────────────────────
|
||||
async function refreshQueue() {
|
||||
try {
|
||||
const data = await api('GET', '/api/print/queue');
|
||||
const queue = data.queue || [];
|
||||
const pending = queue.filter(q => q.status === 'pending');
|
||||
document.getElementById('pending-count').textContent = pending.length;
|
||||
const pending = queue.filter(q => q.status === 'pending').length;
|
||||
document.getElementById('pending-count').textContent = pending;
|
||||
|
||||
const tbody = document.getElementById('queue-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Mise à jour des statuts existants
|
||||
// Mise à jour des badges de statut existants
|
||||
queue.forEach(q => {
|
||||
const row = document.getElementById('row-' + q.id);
|
||||
if (row) {
|
||||
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) {
|
||||
@@ -158,33 +375,30 @@ async function executePrint(id) {
|
||||
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
|
||||
try {
|
||||
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');
|
||||
setTimeout(refreshQueue, 1000);
|
||||
} catch(e) { showToast('Erreur impression', 'error'); }
|
||||
showToast(r.success ? `✅ Impression lancée (${r.printer})` : `❌ ${r.error}`, r.success ? 'success' : 'error');
|
||||
setTimeout(refreshQueue, 1200);
|
||||
} catch(err) { showToast('Erreur impression', 'error'); }
|
||||
}
|
||||
|
||||
async function cancelPrint(id) {
|
||||
try {
|
||||
await api('POST', `/api/print/cancel/${id}`);
|
||||
showToast('Annulé', 'info');
|
||||
showToast('Impression annulée', 'info');
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (row) row.remove();
|
||||
} catch(e) { 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'); }
|
||||
if (row) row.style.opacity = '.4';
|
||||
setTimeout(refreshQueue, 500);
|
||||
} catch(err) { showToast('Erreur', 'error'); }
|
||||
}
|
||||
|
||||
// ── WebSocket + auto-refresh ───────────────────────────────────────────────────
|
||||
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(refreshPrinters, 15000);
|
||||
</script>
|
||||
{% 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
|
||||
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)
|
||||
"""
|
||||
"""JH Photomaton -- Interface de gestion (Les Sapins Du Web)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -22,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from backend.services.config_service import ConfigService
|
||||
from backend.services.event_service import EventService
|
||||
from backend.services.led_service import LEDService
|
||||
from backend.services.button_service import ButtonService
|
||||
from backend.services.photobooth_service import PhotoboothService
|
||||
@@ -37,6 +26,7 @@ from backend.api import (
|
||||
print_api,
|
||||
actions_api,
|
||||
webhooks,
|
||||
event_api,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -50,23 +40,27 @@ BASE_DIR = Path(__file__).parent
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# ── Chargement config ───────────────────────────────────────────────────
|
||||
config_svc = ConfigService(BASE_DIR / "config" / "settings.yaml")
|
||||
config = config_svc.load()
|
||||
|
||||
# ── Services ────────────────────────────────────────────────────────────
|
||||
ws_manager = WSManager()
|
||||
photobooth_svc = PhotoboothService(config)
|
||||
system_svc = SystemService()
|
||||
printer_svc = PrinterService(config)
|
||||
event_svc = EventService()
|
||||
|
||||
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.start()
|
||||
|
||||
# Bouton GPIO — passe le loop asyncio pour les callbacks
|
||||
loop = asyncio.get_event_loop()
|
||||
led_svc.set_loop(loop)
|
||||
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.start()
|
||||
|
||||
# ── État partagé dans l'app ──────────────────────────────────────────────
|
||||
app.state.config = config
|
||||
app.state.config_service = config_svc
|
||||
app.state.ws_manager = ws_manager
|
||||
@@ -83,26 +76,25 @@ async def lifespan(app: FastAPI):
|
||||
app.state.photobooth_service = photobooth_svc
|
||||
app.state.printer_service = printer_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))
|
||||
|
||||
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")
|
||||
|
||||
yield # ← l'app tourne ici
|
||||
yield
|
||||
|
||||
# ── Arrêt propre ─────────────────────────────────────────────────────────
|
||||
stats_task.cancel()
|
||||
button_svc.stop()
|
||||
led_svc.stop()
|
||||
await photobooth_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):
|
||||
"""Broadcast des stats système toutes les 5 secondes."""
|
||||
while True:
|
||||
try:
|
||||
stats = sys_svc.get_stats()
|
||||
@@ -112,29 +104,21 @@ async def _system_stats_loop(sys_svc: SystemService, ws: WSManager):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
# ── Application FastAPI ──────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
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",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key="jh-photomaton-session-2026", # remplacé par config au démarrage
|
||||
max_age=86400, # 24h
|
||||
secret_key="jh-photomaton-session-2026",
|
||||
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(admin_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(actions_api.router, prefix="/api")
|
||||
app.include_router(webhooks.router, prefix="/api")
|
||||
app.include_router(event_api.router, prefix="/api")
|
||||
|
||||
|
||||
# ── WebSocket ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
ws_manager: WSManager = websocket.app.state.ws_manager
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
# Envoie l'état initial à la connexion
|
||||
try:
|
||||
led = websocket.app.state.led_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()})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
# Commandes entrantes du client (optionnel)
|
||||
import json
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
@@ -176,15 +155,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
ws_manager.disconnect(websocket)
|
||||
|
||||
|
||||
# ── Point d'entrée ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
cfg = ConfigService(BASE_DIR / "config" / "settings.yaml").load()
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=cfg.app.host,
|
||||
port=cfg.app.port,
|
||||
reload=cfg.app.debug,
|
||||
log_level="info",
|
||||
)
|
||||
uvicorn.run("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