all
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 00:44:22 +02:00
parent 209f81aa3d
commit 9a006f1457
23 changed files with 3712 additions and 484 deletions
+13
View File
@@ -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)
+167 -91
View File
@@ -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}
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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):
+56 -8
View File
@@ -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
+125
View File
@@ -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()
+61 -67
View File
@@ -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)
+37
View File
@@ -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()
+143 -17
View File
@@ -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
View File
@@ -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 }
+176
View File
@@ -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.
+668
View File
@@ -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 ~450600 Mo RAM à l'idle.
> Lite + cage + Chromium consomme ~150250 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`.
+52
View File
@@ -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 %}
+448 -82
View File
@@ -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()">&times;</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 %}
+309 -95
View File
@@ -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 %}
+823
View File
@@ -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">&#x26A1; 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 &agrave; dominante bleue sur les photos.<br>
R&eacute;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&eacute;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) &mdash; 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()">&#x1F4A1; Tester le flash</button>
<button class="btn btn-primary btn-sm" onclick="saveFlash()">&#x1F4BE; 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 &#x2605;</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 %}
+22 -51
View File
@@ -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")
+191
View File
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"