all
This commit is contained in:
@@ -110,6 +110,19 @@ async def admin_actions(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/settings", response_class=HTMLResponse)
|
||||
async def admin_settings(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
cfg = request.app.state.config
|
||||
return _templates.TemplateResponse("admin/settings.html", {
|
||||
"request": request,
|
||||
"config": cfg,
|
||||
"button": cfg.button,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/print", response_class=HTMLResponse)
|
||||
async def admin_print(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
|
||||
@@ -1,98 +1,15 @@
|
||||
"""API galerie admin — impression et suppression de photos."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/api/gallery/photos")
|
||||
async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
page_photos = photos[start:start + limit]
|
||||
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
|
||||
return {"photos": page_photos, "total": total, "page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit)}
|
||||
|
||||
|
||||
@router.post("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_print_photo(
|
||||
request: Request,
|
||||
photo_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
):
|
||||
"""Impression directe depuis la galerie admin."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
# Reconstruit le chemin du fichier depuis l'ID
|
||||
from pathlib import Path
|
||||
import re
|
||||
cfg = request.app.state.config
|
||||
|
||||
# photobooth-app identifiant → chemin fichier
|
||||
media_dir = cfg.photobooth.media_dir
|
||||
# Cherche le fichier correspondant
|
||||
filename = _find_file(media_dir, photo_id)
|
||||
if not filename:
|
||||
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
|
||||
if cfg.print.mode == "direct":
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry["id"], copies)
|
||||
await ws.broadcast({"type": "print_result", "result": result})
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
else:
|
||||
led.play("error")
|
||||
return result
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
return {"ok": True, "entry": entry, "mode": cfg.print.mode}
|
||||
|
||||
|
||||
@router.delete("/admin/api/gallery/{photo_id}")
|
||||
async def admin_delete_photo(request: Request, photo_id: str):
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
if not request.session.get("authenticated"):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
ok = await pb.delete_media(photo_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
|
||||
return {"ok": ok, "photo_id": photo_id}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
t = item.get("type", item.get("mediaitem_type", "image"))
|
||||
@@ -103,20 +20,179 @@ def _get_id(item: dict) -> str:
|
||||
return str(item.get("id", item.get("filename", item.get("uid", ""))))
|
||||
|
||||
|
||||
def _find_file(media_dir: str, photo_id: str):
|
||||
def _find_file(media_dir: str, photo_id: str) -> Path | None:
|
||||
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
|
||||
from pathlib import Path
|
||||
base = Path(media_dir)
|
||||
if not base.exists():
|
||||
return None
|
||||
|
||||
# L'ID peut être le stem du filename
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
f = base / f"{photo_id}{ext}"
|
||||
if f.exists():
|
||||
return f
|
||||
# Cherche dans les sous-dossiers
|
||||
matches = list(base.rglob(f"{photo_id}{ext}"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def _require_auth(request: Request):
|
||||
return request.session.get("authenticated") is True
|
||||
|
||||
|
||||
# ── Photos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/api/gallery/photos")
|
||||
async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
|
||||
# Récupère toutes les photos
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
page_photos = photos[start:start + limit]
|
||||
|
||||
# Construit les URLs
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["photo_id"] = pid
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
|
||||
# Croise avec la file d'impression en attente (une seule requête SQLite)
|
||||
try:
|
||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
||||
for p in page_photos:
|
||||
pid = p.get("photo_id", "")
|
||||
requests = pending_map.get(pid, [])
|
||||
p["print_requests"] = requests
|
||||
p["print_pending"] = len([r for r in requests if r["status"] == "pending"])
|
||||
p["print_printing"] = len([r for r in requests if r["status"] == "printing"])
|
||||
except Exception as e:
|
||||
logger.warning("Impossible de croiser avec print_queue: %s", e)
|
||||
for p in page_photos:
|
||||
p["print_requests"] = []
|
||||
p["print_pending"] = 0
|
||||
p["print_printing"] = 0
|
||||
|
||||
return {
|
||||
"photos": page_photos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
}
|
||||
|
||||
|
||||
# ── Demandes d'impression ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_print_photo(
|
||||
request: Request,
|
||||
photo_id: str,
|
||||
copies: int = Query(default=1, ge=1, le=3),
|
||||
immediate: bool = Query(default=False, description="True = imprimer maintenant sans passer par la file"),
|
||||
):
|
||||
"""Ajoute une demande d'impression (ou imprime immédiatement si immediate=true).
|
||||
|
||||
En mode 'validation', la demande est mise en file — l'admin valide via /admin/print.
|
||||
En mode 'direct' ou si immediate=true, l'impression est lancée tout de suite.
|
||||
"""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
led = request.app.state.led_service
|
||||
ws = request.app.state.ws_manager
|
||||
cfg = request.app.state.config
|
||||
|
||||
filename = _find_file(cfg.photobooth.media_dir, photo_id)
|
||||
if not filename:
|
||||
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
|
||||
should_print_now = (cfg.print.mode == "direct") or immediate
|
||||
|
||||
if should_print_now:
|
||||
led.play("printing")
|
||||
result = await printer_svc.execute_print(entry["id"], copies)
|
||||
await ws.broadcast({"type": "print_result", "result": result, "photo_id": photo_id})
|
||||
led.play("finished" if result["success"] else "error")
|
||||
return {**result, "entry_id": entry["id"], "photo_id": photo_id}
|
||||
|
||||
# Mode validation : juste en file
|
||||
await ws.broadcast({
|
||||
"type": "print_request",
|
||||
"entry": entry,
|
||||
"photo_id": photo_id,
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"queued": True,
|
||||
"entry_id": entry["id"],
|
||||
"photo_id": photo_id,
|
||||
"mode": cfg.print.mode,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/admin/api/gallery/print/{photo_id}")
|
||||
async def admin_cancel_print_requests(request: Request, photo_id: str):
|
||||
"""Annule toutes les demandes d'impression en attente pour une photo."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
printer_svc = request.app.state.printer_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
count = await printer_svc.cancel_by_photo_id(photo_id)
|
||||
if count:
|
||||
await ws.broadcast({"type": "print_cancelled_for_photo", "photo_id": photo_id, "count": count})
|
||||
return {"ok": True, "photo_id": photo_id, "cancelled": count}
|
||||
|
||||
|
||||
@router.get("/admin/api/gallery/print-status")
|
||||
async def admin_print_status(request: Request):
|
||||
"""Retourne le dict {photo_id_stem: pending_count} pour toute la file.
|
||||
|
||||
Utilisé par la galerie pour mettre à jour les badges sans recharger les photos.
|
||||
"""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
printer_svc = request.app.state.printer_service
|
||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
||||
|
||||
# Format compact : {stem: count} pour minimiser la taille de la réponse
|
||||
return {
|
||||
stem: len(entries)
|
||||
for stem, entries in pending_map.items()
|
||||
}
|
||||
|
||||
|
||||
# ── Suppression ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/admin/api/gallery/{photo_id}")
|
||||
async def admin_delete_photo(request: Request, photo_id: str):
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
if not _require_auth(request):
|
||||
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
ok = await pb.delete_media(photo_id)
|
||||
if ok:
|
||||
await ws.broadcast({"type": "photo_deleted", "photo_id": photo_id})
|
||||
return {"ok": ok, "photo_id": photo_id}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""API de gestion des événements et statistiques."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.services.event_service import slugify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
name: str
|
||||
new_event: bool = False # True = archive l'ancien et crée un nouveau
|
||||
|
||||
|
||||
@router.get("/event")
|
||||
async def get_current_event(request: Request):
|
||||
"""Retourne l'événement en cours avec ses statistiques."""
|
||||
cfg = request.app.state.config
|
||||
event_svc = request.app.state.event_service
|
||||
|
||||
ev = cfg.event
|
||||
stats = await event_svc.get_stats(ev.slug)
|
||||
|
||||
return {
|
||||
"name": ev.name,
|
||||
"slug": ev.slug,
|
||||
"started_at": ev.started_at,
|
||||
"started_at_iso": (
|
||||
datetime.fromtimestamp(ev.started_at).isoformat()
|
||||
if ev.started_at
|
||||
else None
|
||||
),
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/event")
|
||||
async def update_event(request: Request, payload: EventUpdate):
|
||||
"""Met à jour l'événement en cours.
|
||||
|
||||
Si new_event=True : archive le précédent et démarre un nouvel événement
|
||||
(les stats repartent de zéro).
|
||||
Si new_event=False : renomme l'événement en cours (même slug ou nouveau slug,
|
||||
les stats existantes restent sous l'ancien slug).
|
||||
"""
|
||||
cfg = request.app.state.config
|
||||
config_svc = request.app.state.config_service
|
||||
event_svc = request.app.state.event_service
|
||||
|
||||
new_slug = slugify(payload.name)
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
old_slug = cfg.event.slug
|
||||
|
||||
if payload.new_event and old_slug and old_slug != new_slug:
|
||||
# Archive l'ancien événement
|
||||
await event_svc.archive_event(old_slug)
|
||||
|
||||
# Crée ou met à jour la ligne dans la DB
|
||||
await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now)
|
||||
|
||||
# Persiste dans settings.yaml
|
||||
started = now if payload.new_event or not cfg.event.started_at else cfg.event.started_at
|
||||
config_svc.save_event_config(payload.name, new_slug, started)
|
||||
|
||||
stats = await event_svc.get_stats(new_slug)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": payload.name,
|
||||
"slug": new_slug,
|
||||
"started_at": started,
|
||||
"new_event": payload.new_event,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/event/history")
|
||||
async def get_event_history(request: Request):
|
||||
"""Retourne tous les événements passés avec leurs statistiques."""
|
||||
event_svc = request.app.state.event_service
|
||||
history = await event_svc.get_history()
|
||||
|
||||
# Ajoute les dates ISO pour l'affichage
|
||||
for ev in history:
|
||||
if ev.get("started_at"):
|
||||
ev["started_at_iso"] = datetime.fromtimestamp(ev["started_at"]).strftime("%d/%m/%Y %H:%M")
|
||||
if ev.get("ended_at"):
|
||||
ev["ended_at_iso"] = datetime.fromtimestamp(ev["ended_at"]).strftime("%d/%m/%Y %H:%M")
|
||||
else:
|
||||
ev["ended_at_iso"] = None
|
||||
|
||||
return {"events": history}
|
||||
+39
-14
@@ -1,12 +1,15 @@
|
||||
"""Galerie publique — accessible sans authentification."""
|
||||
"""Galerie publique -- accessible sans authentification."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -15,7 +18,6 @@ _templates = Jinja2Templates(directory=Path(__file__).parent.parent.parent / "fr
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""Page d'accueil → redirige vers la galerie publique."""
|
||||
return RedirectResponse(url="/gallery")
|
||||
|
||||
|
||||
@@ -23,7 +25,7 @@ async def index(request: Request):
|
||||
async def gallery_page(request: Request):
|
||||
cfg = request.app.state.config
|
||||
if not cfg.gallery.public_enabled:
|
||||
return HTMLResponse("<h1>Galerie désactivée</h1>", status_code=403)
|
||||
return HTMLResponse("<h1>Galerie desactivee</h1>", status_code=403)
|
||||
return _templates.TemplateResponse("public/gallery.html", {"request": request, "config": cfg})
|
||||
|
||||
|
||||
@@ -33,13 +35,9 @@ async def api_gallery_photos(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
):
|
||||
"""Liste des photos depuis photobooth-app (paginée)."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
|
||||
# Filtre sur les images uniquement
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
total = len(photos)
|
||||
@@ -47,7 +45,6 @@ async def api_gallery_photos(
|
||||
end = start + limit
|
||||
page_photos = photos[start:end]
|
||||
|
||||
# Enrichit avec les URLs
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
@@ -64,9 +61,37 @@ async def api_gallery_photos(
|
||||
|
||||
@router.get("/api/gallery/download/{photo_id}")
|
||||
async def download_photo(request: Request, photo_id: str):
|
||||
"""Redirige vers le fichier full-res sur photobooth-app."""
|
||||
"""Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours."""
|
||||
pb = request.app.state.photobooth_service
|
||||
return RedirectResponse(url=pb.media_url(photo_id))
|
||||
cfg = request.app.state.config
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
|
||||
img_url = pb.media_url(photo_id)
|
||||
slug = cfg.event.slug or "photomaton"
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
filename = f"{slug}_By_LSDW_{date_str}.jpg"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.get(img_url)
|
||||
r.raise_for_status()
|
||||
|
||||
if event_svc:
|
||||
asyncio.create_task(event_svc.increment(slug, "downloads"))
|
||||
|
||||
content_type = r.headers.get("content-type", "image/jpeg")
|
||||
return Response(
|
||||
content=r.content,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(r.content)),
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e)
|
||||
return RedirectResponse(url=img_url)
|
||||
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
|
||||
@@ -49,3 +49,52 @@ async def leds_off(request: Request):
|
||||
led = request.app.state.led_service
|
||||
led.play("off")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/leds/effects")
|
||||
async def get_effects(request: Request):
|
||||
"""Retourne la configuration de tous les effets LED."""
|
||||
cfg = request.app.state.config.leds
|
||||
return {
|
||||
name: {
|
||||
"color": effect.get("color", [0, 0, 0]),
|
||||
"flash_duration": effect.get("flash_duration", 0.1),
|
||||
"flashes": effect.get("flashes", 2),
|
||||
"speed": effect.get("speed", 0.05),
|
||||
"duration": effect.get("duration", 2.0),
|
||||
}
|
||||
for name, effect in cfg.effects.items()
|
||||
}
|
||||
|
||||
|
||||
@router.put("/leds/effect/{effect_name}")
|
||||
async def update_effect(
|
||||
request: Request,
|
||||
effect_name: str,
|
||||
r: int = Query(default=None, ge=0, le=255),
|
||||
g: int = Query(default=None, ge=0, le=255),
|
||||
b: int = Query(default=None, ge=0, le=255),
|
||||
flash_duration: float = Query(default=None, ge=0.05, le=2.0),
|
||||
flashes: int = Query(default=None, ge=1, le=5),
|
||||
save: bool = Query(default=True),
|
||||
):
|
||||
"""Met a jour la configuration d'un effet LED (couleur, timings).
|
||||
|
||||
Exemple : PUT /api/leds/effect/capture?r=255&g=200&b=80&flash_duration=0.3&flashes=2
|
||||
"""
|
||||
cfg = request.app.state.config.leds
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
effect_raw = cfg.effects.setdefault(effect_name, {})
|
||||
|
||||
if r is not None and g is not None and b is not None:
|
||||
effect_raw["color"] = [r, g, b]
|
||||
if flash_duration is not None:
|
||||
effect_raw["flash_duration"] = flash_duration
|
||||
if flashes is not None:
|
||||
effect_raw["flashes"] = flashes
|
||||
|
||||
if save:
|
||||
config_svc.save_led_effect(effect_name, effect_raw)
|
||||
|
||||
return {"ok": True, "effect": effect_name, "config": effect_raw}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API gestion de la file d'attente d'impression."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,6 +34,12 @@ async def print_request(
|
||||
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
|
||||
# Compteur statistiques événement
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "print_requests"))
|
||||
|
||||
# Feedback LED si impression directe
|
||||
if request.app.state.config.print.mode == "direct":
|
||||
led.play("printing")
|
||||
@@ -71,6 +78,10 @@ async def execute_print(
|
||||
|
||||
if result["success"]:
|
||||
led.play("finished")
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "prints_done"))
|
||||
else:
|
||||
led.play("error")
|
||||
|
||||
@@ -112,3 +123,40 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
|
||||
config_svc.save_print_mode(mode)
|
||||
request.app.state.config.print.mode = mode
|
||||
return {"ok": True, "mode": mode}
|
||||
|
||||
|
||||
# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
|
||||
|
||||
@router.post("/print/printers/{printer_name}/enable")
|
||||
async def printer_enable(request: Request, printer_name: str):
|
||||
"""Active une imprimante CUPS (cupsenable + cupsaccept)."""
|
||||
ok = await request.app.state.printer_service.enable_printer(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "enable"}
|
||||
|
||||
|
||||
@router.post("/print/printers/{printer_name}/disable")
|
||||
async def printer_disable(request: Request, printer_name: str):
|
||||
"""Désactive une imprimante CUPS (cupsdisable)."""
|
||||
ok = await request.app.state.printer_service.disable_printer(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "disable"}
|
||||
|
||||
|
||||
@router.post("/print/printers/{printer_name}/reject")
|
||||
async def printer_reject(request: Request, printer_name: str):
|
||||
"""Refuse les nouveaux jobs sans stopper l'impression en cours."""
|
||||
ok = await request.app.state.printer_service.reject_jobs(printer_name)
|
||||
return {"ok": ok, "printer": printer_name, "action": "reject"}
|
||||
|
||||
|
||||
@router.get("/print/printers/{printer_name}/jobs")
|
||||
async def printer_jobs(request: Request, printer_name: str):
|
||||
"""Liste les jobs CUPS en cours pour une imprimante."""
|
||||
jobs = await request.app.state.printer_service.get_cups_jobs(printer_name)
|
||||
return {"printer": printer_name, "jobs": jobs}
|
||||
|
||||
|
||||
@router.delete("/print/printers/jobs/{job_id}")
|
||||
async def cancel_one_job(request: Request, job_id: str):
|
||||
"""Annule un job CUPS précis par son ID."""
|
||||
ok = await request.app.state.printer_service.cancel_cups_job(job_id)
|
||||
return {"ok": ok, "job_id": job_id}
|
||||
|
||||
+111
-1
@@ -1,8 +1,10 @@
|
||||
"""API de surveillance des ressources système."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -50,3 +52,111 @@ async def control_relay(request: Request, state: str = "on"):
|
||||
else:
|
||||
btn.relay_off()
|
||||
return {"ok": True, "relay": state}
|
||||
|
||||
|
||||
@router.get("/system/ui/delete-button")
|
||||
async def get_delete_button(request: Request):
|
||||
"""Retourne l'état actuel du bouton Supprimer de photobooth-app."""
|
||||
visible = request.app.state.config.photobooth.show_delete_button
|
||||
return {"visible": visible}
|
||||
|
||||
|
||||
@router.post("/system/ui/delete-button")
|
||||
async def set_delete_button(request: Request, visible: bool):
|
||||
"""Active ou désactive le bouton Supprimer dans l'UI de review photobooth-app.
|
||||
|
||||
Écrit userdata/private.css en temps réel — effectif à la prochaine capture,
|
||||
sans redémarrage.
|
||||
"""
|
||||
pb = request.app.state.photobooth_service
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
ok = await pb.set_delete_button_visible(visible)
|
||||
if ok:
|
||||
config_svc.save_show_delete_button(visible)
|
||||
|
||||
return {"ok": ok, "visible": visible}
|
||||
|
||||
|
||||
@router.post("/system/screen/refresh")
|
||||
async def screen_refresh(request: Request):
|
||||
"""Envoie F5 à l'écran HDMI connecté (Chromium kiosque Wayland/X11)."""
|
||||
import subprocess, os, asyncio
|
||||
|
||||
# Cherche le display Wayland du user pi (UID 1000)
|
||||
# Essaie wayland-0 puis wayland-1
|
||||
xdg_runtime = "/run/user/1000"
|
||||
cmd_wtype = f"WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5"
|
||||
cmd_x11 = f"DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority xdotool key F5"
|
||||
|
||||
for cmd in [cmd_wtype, cmd_x11]:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
f"su -s /bin/bash pi -c '{cmd}'",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=3)
|
||||
if proc.returncode == 0:
|
||||
logger.info("Screen refresh OK via: %s", cmd.split()[0])
|
||||
return {"ok": True, "method": cmd.split()[0]}
|
||||
except Exception as e:
|
||||
logger.debug("Screen refresh cmd failed (%s): %s", cmd.split()[0], e)
|
||||
|
||||
return JSONResponse({"ok": False, "error": "Aucune méthode disponible (wtype/xdotool)"}, status_code=500)
|
||||
|
||||
|
||||
class ButtonConfigUpdate(BaseModel):
|
||||
double_click_ms: Optional[int] = None
|
||||
long_press_ms: Optional[int] = None
|
||||
debounce_ms: Optional[int] = None
|
||||
max_clicks: Optional[int] = None
|
||||
print_enabled: Optional[bool] = None
|
||||
save: bool = False # True = persiste dans settings.yaml
|
||||
|
||||
|
||||
@router.get("/system/button/config")
|
||||
async def get_button_config(request: Request):
|
||||
"""Retourne la configuration actuelle du bouton (timings)."""
|
||||
cfg = request.app.state.config.button
|
||||
return {
|
||||
"double_click_ms": cfg.double_click_ms,
|
||||
"long_press_ms": cfg.long_press_ms,
|
||||
"debounce_ms": cfg.debounce_ms,
|
||||
"max_clicks": cfg.max_clicks,
|
||||
"print_enabled": cfg.print_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/system/button/config")
|
||||
async def update_button_config(request: Request, payload: ButtonConfigUpdate):
|
||||
"""Met à jour les timings du bouton (à chaud + optionnellement sauvegardé)."""
|
||||
btn = request.app.state.button_service
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
update = {k: v for k, v in payload.model_dump(exclude={"save"}).items() if v is not None}
|
||||
|
||||
# Application immédiate (sans redémarrage)
|
||||
btn.update_timings(
|
||||
double_click_ms=payload.double_click_ms,
|
||||
long_press_ms=payload.long_press_ms,
|
||||
debounce_ms=payload.debounce_ms,
|
||||
max_clicks=payload.max_clicks,
|
||||
)
|
||||
|
||||
# Persistance optionnelle
|
||||
if payload.save and update:
|
||||
config_svc.save_button_config(update)
|
||||
|
||||
cfg = request.app.state.config.button
|
||||
return {
|
||||
"ok": True,
|
||||
"saved": payload.save,
|
||||
"current": {
|
||||
"double_click_ms": cfg.double_click_ms,
|
||||
"long_press_ms": cfg.long_press_ms,
|
||||
"debounce_ms": cfg.debounce_ms,
|
||||
"max_clicks": cfg.max_clicks,
|
||||
"print_enabled": cfg.print_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
+8
-40
@@ -1,16 +1,6 @@
|
||||
"""Webhooks reçus de photobooth-app via plugin_commander.
|
||||
|
||||
photobooth-app envoie un GET sur /api/webhook/photobooth?event_key=XXX&mediaitem_type=image
|
||||
pour chaque événement de capture.
|
||||
|
||||
Événements :
|
||||
counting → countdown en cours (LEDs remplissage vert)
|
||||
capture → flash photo (LEDs flash blanc)
|
||||
captured → photo prise (LEDs violet)
|
||||
finished → traitement terminé (LEDs bleu → retour idle + relay ON)
|
||||
start/stop → démarrage/arrêt photobooth-app
|
||||
"""
|
||||
"""Webhooks recus de photobooth-app via plugin_commander."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
@@ -18,7 +8,6 @@ from fastapi import APIRouter, Request, Query
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Durée par défaut du countdown (en secondes) — peut être overridé par action
|
||||
DEFAULT_COUNTDOWN = 5.0
|
||||
|
||||
|
||||
@@ -33,30 +22,25 @@ async def photobooth_webhook(
|
||||
ws = request.app.state.ws_manager
|
||||
|
||||
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
|
||||
|
||||
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
|
||||
|
||||
match event_key:
|
||||
case "counting":
|
||||
# Countdown en cours — remplissage LED vert
|
||||
# On cherche la durée du countdown dans la config de l'action courante
|
||||
countdown_duration = _get_countdown_duration(request)
|
||||
led.play("countdown", countdown_duration=countdown_duration)
|
||||
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
||||
|
||||
case "capture":
|
||||
# Flash photo
|
||||
led.play("capture")
|
||||
|
||||
case "captured":
|
||||
# Photo prise, traitement en cours
|
||||
led.play("captured")
|
||||
|
||||
case "finished":
|
||||
# Traitement terminé
|
||||
led.play("finished")
|
||||
# Réactive le relay après l'animation "finished" (durée ~2s gérée par le service LED)
|
||||
import asyncio
|
||||
asyncio.create_task(_delayed_relay_on(btn, 2.5))
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
slug = request.app.state.config.event.slug
|
||||
asyncio.create_task(event_svc.increment(slug, "photos_taken"))
|
||||
|
||||
case "start":
|
||||
led.play("idle")
|
||||
@@ -65,28 +49,12 @@ async def photobooth_webhook(
|
||||
led.play("off")
|
||||
|
||||
case _:
|
||||
logger.debug("Événement inconnu: %s", event_key)
|
||||
logger.debug("Evenement inconnu: %s", event_key)
|
||||
|
||||
return {"ok": True, "event": event_key}
|
||||
|
||||
|
||||
async def _delayed_relay_on(btn, delay: float):
|
||||
import asyncio
|
||||
await asyncio.sleep(delay)
|
||||
if btn:
|
||||
btn.relay_on()
|
||||
|
||||
|
||||
def _get_countdown_duration(request: Request) -> float:
|
||||
"""
|
||||
Essaie de récupérer la durée du countdown depuis la config photobooth-app
|
||||
en lisant le dernier index d'action déclenché par le bouton.
|
||||
Retourne la valeur par défaut si indisponible.
|
||||
"""
|
||||
try:
|
||||
btn = request.app.state.button_service
|
||||
# On pourrait stocker le dernier index dans le button_service
|
||||
# Pour l'instant on retourne 5s par défaut
|
||||
return DEFAULT_COUNTDOWN
|
||||
except Exception:
|
||||
return DEFAULT_COUNTDOWN
|
||||
|
||||
@@ -42,6 +42,7 @@ class ButtonService:
|
||||
self._click_count = 0
|
||||
self._press_time: float = 0.0
|
||||
self._click_timer: threading.Timer | None = None
|
||||
self._long_timer: threading.Timer | None = None # ← fix: init explicite
|
||||
self._long_press_fired = False
|
||||
self._relay_enabled = True
|
||||
self._lock = threading.Lock()
|
||||
@@ -110,6 +111,33 @@ class ButtonService:
|
||||
def simulate_long_press(self):
|
||||
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
|
||||
|
||||
def update_timings(
|
||||
self,
|
||||
double_click_ms: int | None = None,
|
||||
long_press_ms: int | None = None,
|
||||
debounce_ms: int | None = None,
|
||||
max_clicks: int | None = None,
|
||||
):
|
||||
"""Met à jour les timings à chaud (sans redémarrage du service)."""
|
||||
if double_click_ms is not None:
|
||||
self._cfg.double_click_ms = double_click_ms
|
||||
if long_press_ms is not None:
|
||||
self._cfg.long_press_ms = long_press_ms
|
||||
if max_clicks is not None:
|
||||
self._cfg.max_clicks = max(1, min(4, max_clicks))
|
||||
if debounce_ms is not None:
|
||||
self._cfg.debounce_ms = debounce_ms
|
||||
if self._button:
|
||||
try:
|
||||
self._button.bounce_time = debounce_ms / 1000
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"Timings bouton mis à jour : double_click=%dms, long_press=%dms, debounce=%dms, max_clicks=%d",
|
||||
self._cfg.double_click_ms, self._cfg.long_press_ms,
|
||||
self._cfg.debounce_ms, self._cfg.max_clicks,
|
||||
)
|
||||
|
||||
# ── Callbacks GPIO ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_pressed(self):
|
||||
|
||||
@@ -28,6 +28,7 @@ class PhotoboothConfig:
|
||||
config_file: str = "/home/pi/.config/photobooth-app/config.json"
|
||||
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
|
||||
userdata_dir: str = "/home/pi/photobooth-data/userdata"
|
||||
show_delete_button: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,6 +90,13 @@ class GalleryConfig:
|
||||
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventConfig:
|
||||
name: str = "Evenement"
|
||||
slug: str = "evenement"
|
||||
started_at: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
app: AppConfig = field(default_factory=AppConfig)
|
||||
@@ -97,6 +105,7 @@ class Config:
|
||||
leds: LEDConfig = field(default_factory=LEDConfig)
|
||||
print: PrintConfig = field(default_factory=PrintConfig)
|
||||
gallery: GalleryConfig = field(default_factory=GalleryConfig)
|
||||
event: EventConfig = field(default_factory=EventConfig)
|
||||
button_actions: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -107,7 +116,7 @@ class ConfigService:
|
||||
|
||||
def load(self) -> Config:
|
||||
if not self.path.exists():
|
||||
logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
|
||||
logger.warning("Config introuvable: %s", self.path)
|
||||
self._config = Config()
|
||||
return self._config
|
||||
|
||||
@@ -143,30 +152,60 @@ class ConfigService:
|
||||
if "gallery" in raw:
|
||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
|
||||
|
||||
if "event" in raw:
|
||||
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
|
||||
|
||||
cfg.button_actions = raw.get("button_actions", {
|
||||
1: {"label": "Photo normale", "photobooth_index": 0},
|
||||
2: {"label": "Photo étoile", "photobooth_index": 1},
|
||||
2: {"label": "Photo etoile", "photobooth_index": 1},
|
||||
3: {"label": "Photo cailloux", "photobooth_index": 2},
|
||||
4: {"label": "Photo soirée", "photobooth_index": 3},
|
||||
4: {"label": "Photo soiree", "photobooth_index": 3},
|
||||
})
|
||||
|
||||
self._config = cfg
|
||||
logger.info("Configuration chargée depuis %s", self.path)
|
||||
logger.info("Configuration chargee depuis %s", self.path)
|
||||
return cfg
|
||||
|
||||
def save_button_actions(self, button_actions: dict):
|
||||
"""Met à jour uniquement la section button_actions dans settings.yaml."""
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
raw["button_actions"] = button_actions
|
||||
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
if self._config:
|
||||
self._config.button_actions = button_actions
|
||||
|
||||
def save_show_delete_button(self, visible: bool):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("photobooth", {})["show_delete_button"] = visible
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.photobooth.show_delete_button = visible
|
||||
|
||||
def save_button_config(self, data: dict):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("button", {}).update(data)
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
for k, v in data.items():
|
||||
if hasattr(self._config.button, k):
|
||||
setattr(self._config.button, k, v)
|
||||
|
||||
def save_event_config(self, name: str, slug: str, started_at: float):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw["event"] = {"name": name, "slug": slug, "started_at": started_at}
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.event.name = name
|
||||
self._config.event.slug = slug
|
||||
self._config.event.started_at = started_at
|
||||
|
||||
def save_print_mode(self, mode: str):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
@@ -181,3 +220,12 @@ class ConfigService:
|
||||
if self._config is None:
|
||||
self.load()
|
||||
return self._config
|
||||
|
||||
def save_led_effect(self, effect_name: str, effect_data: dict):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("leds", {}).setdefault("effects", {})[effect_name] = effect_data
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
if self._config:
|
||||
self._config.leds.effects[effect_name] = effect_data
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Service de gestion des événements et statistiques.
|
||||
|
||||
Chaque événement (soirée, mariage, fête...) a ses propres compteurs :
|
||||
- photos_taken : incrémenté par le webhook 'finished' de photobooth-app
|
||||
- print_requests : incrémenté à chaque demande d'impression dans la file
|
||||
- prints_done : incrémenté à chaque impression réussie
|
||||
- downloads : incrémenté à chaque téléchargement via /api/gallery/download/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Transforme un nom d'événement en slug ASCII sans accents ni espaces."""
|
||||
# Normalise les caractères Unicode (enlève les accents)
|
||||
nfkd = unicodedata.normalize("NFD", name)
|
||||
ascii_str = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
|
||||
ascii_str = ascii_str.lower()
|
||||
# Garde lettres, chiffres, espaces, tirets
|
||||
ascii_str = re.sub(r"[^\w\s-]", "", ascii_str)
|
||||
# Remplace espaces/tirets multiples par un underscore
|
||||
ascii_str = re.sub(r"[\s_-]+", "_", ascii_str)
|
||||
return ascii_str.strip("_") or "evenement"
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
photos_taken INTEGER NOT NULL DEFAULT 0,
|
||||
print_requests INTEGER NOT NULL DEFAULT 0,
|
||||
prints_done INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
_VALID_COUNTERS = {"photos_taken", "print_requests", "prints_done", "downloads"}
|
||||
|
||||
|
||||
class EventService:
|
||||
def __init__(self):
|
||||
self._db: aiosqlite.Connection | None = None
|
||||
|
||||
async def init_db(self, db_path: Path):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db = await aiosqlite.connect(str(db_path))
|
||||
self._db.row_factory = aiosqlite.Row
|
||||
await self._db.execute(_SCHEMA)
|
||||
await self._db.commit()
|
||||
logger.info("EventService initialisé : %s", db_path)
|
||||
|
||||
async def ensure_event(self, slug: str, name: str, started_at: float):
|
||||
"""Crée la ligne pour cet événement si elle n'existe pas encore."""
|
||||
await self._db.execute(
|
||||
"""INSERT INTO events (name, slug, started_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET name = excluded.name""",
|
||||
(name, slug, started_at),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def archive_event(self, slug: str):
|
||||
"""Marque un événement comme terminé (ended_at = maintenant)."""
|
||||
await self._db.execute(
|
||||
"UPDATE events SET ended_at = ? WHERE slug = ? AND ended_at IS NULL",
|
||||
(datetime.now().timestamp(), slug),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def increment(self, slug: str, counter: str):
|
||||
"""Incrémente un compteur de l'événement identifié par son slug.
|
||||
|
||||
Crée automatiquement une ligne si elle n'existe pas.
|
||||
"""
|
||||
if counter not in _VALID_COUNTERS:
|
||||
logger.warning("Compteur inconnu : %s", counter)
|
||||
return
|
||||
# Upsert : insère si absent, sinon incrémente
|
||||
await self._db.execute(
|
||||
f"""INSERT INTO events (name, slug, started_at, {counter})
|
||||
VALUES (?, ?, unixepoch(), 1)
|
||||
ON CONFLICT(slug) DO UPDATE SET {counter} = {counter} + 1""",
|
||||
(slug, slug),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def get_stats(self, slug: str) -> dict:
|
||||
"""Retourne les stats pour un slug donné."""
|
||||
async with self._db.execute(
|
||||
"""SELECT photos_taken, print_requests, prints_done, downloads
|
||||
FROM events WHERE slug = ?""",
|
||||
(slug,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
return {"photos_taken": 0, "print_requests": 0, "prints_done": 0, "downloads": 0}
|
||||
return dict(row)
|
||||
|
||||
async def get_history(self) -> list[dict]:
|
||||
"""Retourne tous les événements triés du plus récent au plus ancien."""
|
||||
async with self._db.execute(
|
||||
"""SELECT id, name, slug, started_at, ended_at,
|
||||
photos_taken, print_requests, prints_done, downloads
|
||||
FROM events
|
||||
ORDER BY started_at DESC"""
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def close(self):
|
||||
if self._db:
|
||||
await self._db.close()
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
|
||||
"""Service de controle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
|
||||
|
||||
Mode réel : rpi_ws281x (Raspberry Pi, doit tourner en root ou avec /dev/mem)
|
||||
Mode mock : log des opérations uniquement (développement)
|
||||
Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem)
|
||||
Mode mock : log uniquement (developpement)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
@@ -16,13 +17,12 @@ from backend.services.config_service import Config, LEDConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
|
||||
try:
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
|
||||
from rpi_ws281x import PixelStrip, Color as WS_Color
|
||||
HAS_WS281X = True
|
||||
except ImportError:
|
||||
HAS_WS281X = False
|
||||
logger.warning("rpi_ws281x non disponible — mode mock LED activé")
|
||||
logger.warning("rpi_ws281x non disponible -- mode mock LED")
|
||||
|
||||
class WS_Color: # type: ignore
|
||||
def __init__(self, r: int, g: int, b: int):
|
||||
@@ -31,7 +31,7 @@ except ImportError:
|
||||
return f"Color({self.r},{self.g},{self.b})"
|
||||
|
||||
class PixelStrip: # type: ignore
|
||||
def __init__(self, *args, **kwargs): pass
|
||||
def __init__(self, *a, **kw): pass
|
||||
def begin(self): pass
|
||||
def show(self): pass
|
||||
def setPixelColor(self, i, c): pass
|
||||
@@ -40,16 +40,10 @@ except ImportError:
|
||||
|
||||
|
||||
def _color(rgb: list[int]) -> WS_Color:
|
||||
return WS_Color(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def _lerp(a: int, b: int, t: float) -> int:
|
||||
return int(a + (b - a) * t)
|
||||
return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
||||
|
||||
|
||||
class LEDService:
|
||||
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
|
||||
|
||||
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
|
||||
"printing", "error", "disabled", "off")
|
||||
|
||||
@@ -74,16 +68,16 @@ class LEDService:
|
||||
self._cfg.pin,
|
||||
self._cfg.freq_hz,
|
||||
self._cfg.dma,
|
||||
False, # invert
|
||||
False,
|
||||
self._cfg.brightness,
|
||||
0, # channel
|
||||
0,
|
||||
)
|
||||
try:
|
||||
self._strip.begin()
|
||||
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
logger.info("Strip WS2812b initialise (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
|
||||
except Exception as e:
|
||||
logger.error("Erreur init strip LED: %s", e)
|
||||
self._strip = PixelStrip() # fallback mock
|
||||
self._strip = PixelStrip()
|
||||
else:
|
||||
self._strip = PixelStrip()
|
||||
|
||||
@@ -104,10 +98,9 @@ class LEDService:
|
||||
def set_on_change(self, cb: Callable):
|
||||
self._on_change_cb = cb
|
||||
|
||||
# ── Commandes publiques (thread-safe) ────────────────────────────────────
|
||||
# ── Commandes publiques ───────────────────────────────────────────────────
|
||||
|
||||
def play(self, effect: str, countdown_duration: float = 5.0):
|
||||
"""Joue un effet nommé. Thread-safe."""
|
||||
with self._lock:
|
||||
self._current_effect = effect
|
||||
self._countdown_duration = countdown_duration
|
||||
@@ -116,22 +109,21 @@ class LEDService:
|
||||
self._notify_change(effect)
|
||||
|
||||
def set_color(self, r: int, g: int, b: int):
|
||||
"""Couleur fixe immédiate."""
|
||||
self._fill(WS_Color(r, g, b))
|
||||
|
||||
# ── Thread principal ──────────────────────────────────────────────────────
|
||||
|
||||
def _run(self):
|
||||
effect_func = {
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
"idle": self._effect_idle,
|
||||
"countdown": self._effect_countdown,
|
||||
"capture": self._effect_capture,
|
||||
"captured": self._effect_solid,
|
||||
"finished": self._effect_finished,
|
||||
"printing": self._effect_spin,
|
||||
"error": self._effect_error,
|
||||
"disabled": self._effect_solid,
|
||||
"off": self._effect_off,
|
||||
}
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
@@ -143,19 +135,16 @@ class LEDService:
|
||||
fn = effect_func.get(current, self._effect_idle)
|
||||
|
||||
try:
|
||||
if current in ("countdown",):
|
||||
if current == "countdown":
|
||||
fn(cd_dur)
|
||||
else:
|
||||
fn()
|
||||
except Exception as e:
|
||||
logger.error("Erreur effet LED '%s': %s", current, e)
|
||||
|
||||
# Si l'effet s'est terminé naturellement (ex: finished → retour idle)
|
||||
# on vérifie si un nouveau cmd est arrivé
|
||||
if not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
with self._lock:
|
||||
if self._current_effect == current:
|
||||
# Retour automatique à idle après effets ponctuels
|
||||
if current in ("capture", "captured", "finished", "error"):
|
||||
self._current_effect = "idle"
|
||||
self._cmd_event.wait(timeout=0.1)
|
||||
@@ -163,27 +152,23 @@ class LEDService:
|
||||
# ── Effets ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _effect_idle(self):
|
||||
"""Respiration bleue douce."""
|
||||
cfg = self._cfg.get_effect("idle")
|
||||
c = cfg.color
|
||||
speed = cfg.speed
|
||||
n = self._cfg.count
|
||||
|
||||
step = 0
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
|
||||
t = (1 + math.sin(step * 0.1)) / 2
|
||||
brightness = max(0.05, t)
|
||||
color = WS_Color(
|
||||
self._fill(WS_Color(
|
||||
int(c[0] * brightness),
|
||||
int(c[1] * brightness),
|
||||
int(c[2] * brightness),
|
||||
)
|
||||
self._fill(color)
|
||||
))
|
||||
time.sleep(speed)
|
||||
step += 1
|
||||
|
||||
def _effect_countdown(self, duration: float = 5.0):
|
||||
"""Remplissage progressif vert LED par LED."""
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
@@ -191,55 +176,68 @@ class LEDService:
|
||||
color = WS_Color(c[0], c[1], c[2])
|
||||
|
||||
self._fill(blank)
|
||||
|
||||
t_start = time.time()
|
||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||
elapsed = time.time() - t_start
|
||||
ratio = min(elapsed / duration, 1.0)
|
||||
leds_on = int(ratio * n)
|
||||
|
||||
for i in range(n):
|
||||
self._strip.setPixelColor(i, color if i < leds_on else blank)
|
||||
self._strip.show()
|
||||
|
||||
if ratio >= 1.0:
|
||||
break
|
||||
time.sleep(0.04)
|
||||
|
||||
def _effect_capture(self):
|
||||
"""Flash blanc."""
|
||||
"""Flash photo.
|
||||
|
||||
Utilise la couleur configuree dans settings.yaml > leds > effects > capture.
|
||||
Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage,
|
||||
puis la restaure a la valeur normale.
|
||||
|
||||
Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges.
|
||||
Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255].
|
||||
"""
|
||||
cfg = self._cfg.get_effect("capture")
|
||||
white = WS_Color(255, 255, 255)
|
||||
flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(white)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
# Boost luminosite max pendant le flash
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(255)
|
||||
|
||||
try:
|
||||
for _ in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(flash_color)
|
||||
time.sleep(cfg.flash_duration)
|
||||
self._fill(off)
|
||||
# Pause inter-flash plus courte que le flash lui-meme
|
||||
if _ < cfg.flashes - 1:
|
||||
time.sleep(cfg.flash_duration * 0.4)
|
||||
finally:
|
||||
# Toujours restaurer la luminosite normale meme en cas d'erreur
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(self._cfg.brightness)
|
||||
self._fill(off)
|
||||
time.sleep(cfg.flash_duration)
|
||||
|
||||
def _effect_solid(self):
|
||||
"""Couleur pleine selon l'effet courant."""
|
||||
with self._lock:
|
||||
current = self._current_effect
|
||||
cfg = self._cfg.get_effect(current)
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
# Attente jusqu'à prochain cmd
|
||||
self._cmd_event.wait()
|
||||
|
||||
def _effect_finished(self):
|
||||
"""Bleu fixe pendant duration secondes, puis retour idle."""
|
||||
cfg = self._cfg.get_effect("finished")
|
||||
self._fill(WS_Color(*cfg.color))
|
||||
self._cmd_event.wait(timeout=cfg.duration)
|
||||
|
||||
def _effect_spin(self):
|
||||
"""Rotation d'une traînée de LEDs."""
|
||||
cfg = self._cfg.get_effect("printing")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
speed = cfg.speed
|
||||
tail = 6
|
||||
|
||||
pos = 0
|
||||
@@ -248,22 +246,18 @@ class LEDService:
|
||||
dist = (i - pos) % n
|
||||
if dist < tail:
|
||||
factor = (tail - dist) / tail
|
||||
self._strip.setPixelColor(
|
||||
i,
|
||||
WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
),
|
||||
)
|
||||
self._strip.setPixelColor(i, WS_Color(
|
||||
int(c[0] * factor),
|
||||
int(c[1] * factor),
|
||||
int(c[2] * factor),
|
||||
))
|
||||
else:
|
||||
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
|
||||
self._strip.show()
|
||||
pos = (pos + 1) % n
|
||||
time.sleep(speed)
|
||||
time.sleep(cfg.speed)
|
||||
|
||||
def _effect_error(self):
|
||||
"""Flash rouge."""
|
||||
cfg = self._cfg.get_effect("error")
|
||||
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||
off = WS_Color(0, 0, 0)
|
||||
|
||||
@@ -125,5 +125,42 @@ class PhotoboothService:
|
||||
results.append(str(f))
|
||||
return sorted(results)
|
||||
|
||||
# ── UI / private.css ──────────────────────────────────────────────────────
|
||||
|
||||
_CSS_HIDE_DELETE = """\
|
||||
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
|
||||
/* Cacher le bouton Supprimer sur l'écran de review après capture */
|
||||
.action-button-delete {
|
||||
display: none !important;
|
||||
}
|
||||
"""
|
||||
|
||||
_CSS_SHOW_DELETE = """\
|
||||
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
|
||||
/* Bouton Supprimer visible (activé depuis le dashboard JH Photomaton) */
|
||||
/* .action-button-delete { display: none !important; } */
|
||||
"""
|
||||
|
||||
async def set_delete_button_visible(self, visible: bool) -> bool:
|
||||
"""Écrit private.css dans userdata pour afficher ou cacher le bouton Supprimer.
|
||||
|
||||
photobooth-app charge automatiquement userdata/private.css à chaque requête
|
||||
— aucun redémarrage nécessaire, effectif dès la prochaine capture.
|
||||
"""
|
||||
css_path = Path(self._cfg.userdata_dir) / "private.css"
|
||||
try:
|
||||
css_content = self._CSS_SHOW_DELETE if visible else self._CSS_HIDE_DELETE
|
||||
css_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
css_path.write_text(css_content, encoding="utf-8")
|
||||
logger.info("private.css mis à jour : bouton delete %s", "visible" if visible else "caché")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Impossible d'écrire private.css : %s", e)
|
||||
return False
|
||||
|
||||
async def get_delete_button_visible(self) -> bool:
|
||||
"""Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
|
||||
return self._cfg.show_delete_button
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -100,6 +100,35 @@ class PrinterService:
|
||||
async def get_pending(self) -> list[dict]:
|
||||
return await self.get_queue("pending")
|
||||
|
||||
async def get_pending_by_photo_id(self) -> dict[str, list]:
|
||||
"""Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives.
|
||||
|
||||
Permet à la galerie admin de savoir quelles photos ont une demande en attente
|
||||
sans modifier le schéma SQLite — on match par stem du filename.
|
||||
"""
|
||||
rows = await self.get_queue("pending")
|
||||
# Aussi inclure celles "printing" (en cours d'impression)
|
||||
rows += await self.get_queue("printing")
|
||||
|
||||
result: dict[str, list] = {}
|
||||
for r in rows:
|
||||
from pathlib import Path
|
||||
stem = Path(r["filename"]).stem
|
||||
result.setdefault(stem, []).append(r)
|
||||
return result
|
||||
|
||||
async def cancel_by_photo_id(self, photo_stem: str) -> int:
|
||||
"""Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé."""
|
||||
pending = await self.get_queue("pending")
|
||||
from pathlib import Path
|
||||
cancelled = 0
|
||||
for r in pending:
|
||||
if Path(r["filename"]).stem == photo_stem:
|
||||
ok = await self.cancel(r["id"])
|
||||
if ok:
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
async def cancel(self, entry_id: str) -> bool:
|
||||
async with self._lock:
|
||||
cursor = await self._db.execute(
|
||||
@@ -186,48 +215,105 @@ class PrinterService:
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _printer_name(self, p) -> str:
|
||||
"""Accepte une entrée printers qui soit un str ou un dict."""
|
||||
return p["name"] if isinstance(p, dict) else str(p)
|
||||
|
||||
def _printer_label(self, p) -> str:
|
||||
return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p)
|
||||
|
||||
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
|
||||
|
||||
async def get_printers_status(self) -> list[dict]:
|
||||
"""Retourne le statut des imprimantes CUPS configurées."""
|
||||
"""Retourne le statut détaillé des imprimantes CUPS configurées."""
|
||||
statuses = []
|
||||
for p in self._cfg.printers:
|
||||
status = await asyncio.to_thread(self._get_printer_status, p["name"])
|
||||
name = self._printer_name(p)
|
||||
status = await asyncio.to_thread(self._get_printer_status, name)
|
||||
statuses.append({
|
||||
"name": p["name"],
|
||||
"label": p["label"],
|
||||
"name": name,
|
||||
"label": self._printer_label(p),
|
||||
**status,
|
||||
})
|
||||
return statuses
|
||||
|
||||
def _get_printer_status(self, printer_name: str) -> dict:
|
||||
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# État de l'imprimante
|
||||
r_state = subprocess.run(
|
||||
["lpstat", "-p", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
output = result.stdout.lower()
|
||||
if "idle" in output:
|
||||
out = r_state.stdout.lower()
|
||||
|
||||
if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower():
|
||||
return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0}
|
||||
|
||||
if "idle" in out:
|
||||
state = "idle"
|
||||
elif "printing" in output or "processing" in output:
|
||||
elif "printing" in out or "processing" in out:
|
||||
state = "printing"
|
||||
elif "disabled" in output:
|
||||
elif "stopped" in out or "disabled" in out:
|
||||
state = "disabled"
|
||||
elif "not found" in output or result.returncode != 0:
|
||||
state = "offline"
|
||||
else:
|
||||
state = "unknown"
|
||||
|
||||
# Compte les jobs en attente
|
||||
jobs_result = subprocess.run(
|
||||
["lpstat", "-o", printer_name],
|
||||
# Est-ce que l'imprimante accepte les nouveaux jobs ?
|
||||
r_accept = subprocess.run(
|
||||
["lpstat", "-a", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l])
|
||||
accepting = "accepting" in r_accept.stdout.lower()
|
||||
|
||||
return {"state": state, "jobs": jobs}
|
||||
# Liste des jobs CUPS en cours
|
||||
jobs = self._get_cups_jobs(printer_name)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"accepting": accepting,
|
||||
"jobs": jobs,
|
||||
"jobs_count": len(jobs),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"state": "error", "jobs": 0, "error": str(e)}
|
||||
return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
|
||||
|
||||
def _get_cups_jobs(self, printer_name: str) -> list[dict]:
|
||||
"""Retourne la liste des jobs CUPS en cours pour une imprimante."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["lpstat", "-o", printer_name, "-l"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
jobs = []
|
||||
current = {}
|
||||
for line in r.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(printer_name):
|
||||
if current:
|
||||
jobs.append(current)
|
||||
parts = line.split()
|
||||
current = {
|
||||
"id": parts[0] if parts else "",
|
||||
"user": parts[1] if len(parts) > 1 else "",
|
||||
"size": parts[3] if len(parts) > 3 else "",
|
||||
"date": " ".join(parts[4:7]) if len(parts) > 6 else "",
|
||||
"details": [],
|
||||
}
|
||||
elif current and ":" in line:
|
||||
current.setdefault("details", []).append(line)
|
||||
if current:
|
||||
jobs.append(current)
|
||||
return jobs
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_cups_jobs(self, printer_name: str) -> list[dict]:
|
||||
return await asyncio.to_thread(self._get_cups_jobs, printer_name)
|
||||
|
||||
async def cancel_cups_jobs(self, printer_name: str) -> bool:
|
||||
"""Annule tous les jobs CUPS pour une imprimante."""
|
||||
@@ -240,3 +326,43 @@ class PrinterService:
|
||||
except Exception as e:
|
||||
logger.error("Erreur cancel CUPS: %s", e)
|
||||
return False
|
||||
|
||||
async def cancel_cups_job(self, job_id: str) -> bool:
|
||||
"""Annule un job CUPS précis."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cancel", job_id],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur cancel CUPS job %s: %s", job_id, e)
|
||||
return False
|
||||
|
||||
async def enable_printer(self, printer_name: str) -> bool:
|
||||
"""Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs."""
|
||||
try:
|
||||
r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10)
|
||||
r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10)
|
||||
return r1.returncode == 0 and r2.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur enable printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
async def disable_printer(self, printer_name: str) -> bool:
|
||||
"""Désactive l'imprimante CUPS (cupsdisable)."""
|
||||
try:
|
||||
result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur disable printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
async def reject_jobs(self, printer_name: str) -> bool:
|
||||
"""Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours."""
|
||||
try:
|
||||
result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error("Erreur reject printer %s: %s", printer_name, e)
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user