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