Compare commits
38 Commits
7fa1b49a9b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b2d3bb1833 | |||
| fc9e9c7def | |||
| bc5f9213ec | |||
| 35233c0a43 | |||
| 1419600969 | |||
| 370d08371a | |||
| 69b9cd0901 | |||
| b2ee41c367 | |||
| 5142d6c8d9 | |||
| ffa25646b9 | |||
| 19557b20fb | |||
| f192ffc92f | |||
| fb35533b37 | |||
| 3522d2d996 | |||
| 2b99ef6469 | |||
| dd5c4be555 | |||
| 94def589bd | |||
| 0b612e1302 | |||
| d6642c2519 | |||
| 4388503102 | |||
| 2393396948 | |||
| 5bc55c7591 | |||
| 31f4023f16 | |||
| 8a70dea240 | |||
| 68b0a36031 | |||
| c32b3ba345 | |||
| 742186ec8e | |||
| 4592f20be3 | |||
| 11bd91c386 | |||
| c89924ea18 | |||
| 223d71883e | |||
| f6d4eafd20 | |||
| fed824fbb1 | |||
| 4629962371 | |||
| 35faf19db0 | |||
| b4426e9738 | |||
| d5f111d60b | |||
| 7d60eba86d |
@@ -0,0 +1,24 @@
|
||||
# JH Photomaton — droits sudo sans mot de passe pour la gestion des services
|
||||
#
|
||||
# INSTALLATION :
|
||||
# sudo cp sudoers-jh-photomaton /etc/sudoers.d/jh-photomaton
|
||||
# sudo chmod 440 /etc/sudoers.d/jh-photomaton
|
||||
# sudo visudo -cf /etc/sudoers.d/jh-photomaton # vérification syntaxe
|
||||
#
|
||||
# Ces règles permettent à l'utilisateur "pi" de redémarrer les services
|
||||
# nécessaires au photomaton ainsi que de rebooter le Pi, sans mot de passe.
|
||||
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-app.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-kiosk.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart zoraxy.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart hostapd.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart dnsmasq.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart cups.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart jh-photomaton.service
|
||||
pi ALL=(ALL) NOPASSWD: /usr/sbin/reboot
|
||||
|
||||
# Gestion des imprimantes CUPS (boutons activer/désactiver dans l'admin impression)
|
||||
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsenable
|
||||
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsdisable
|
||||
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsaccept
|
||||
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsreject
|
||||
@@ -8,10 +8,22 @@
|
||||
# Imprimantes: Canon Selphy CP1300 x2 via WiFi (blanche + noire)
|
||||
# Load balancing: utilise l'imprimante idle, sinon file d'attente sur la 1ere
|
||||
#
|
||||
# Sortie stdout parsable par Node-RED:
|
||||
# PRINTED:<printer_name>:<copies> en cas de succes
|
||||
# PRINT_ERROR:<printer_name> en cas d'erreur
|
||||
# Sortie stdout parsable par printer_service.py:
|
||||
# PRINTED:<printer_name>:<copies> en cas de succes
|
||||
# PRINT_ERROR:<printer_name>:<raison> en cas d'erreur
|
||||
# =============================================================================
|
||||
#
|
||||
# ── CALIBRAGE IMPRESSION ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Selphy CP1300 — papier Postcard borderless 100x148mm
|
||||
# Image traitée par photobooth-app : 2000x1333
|
||||
# Overscan : 2114x1418 avec fond blanc (la Selphy rogne les bords en borderless)
|
||||
# Décalage : -roll -12+8 (calibré pour l'offset naturel de l'imprimante)
|
||||
# Option CUPS : PageSize=Postcard.Borderless (défaut imprimante = om_postcard-borderless_100x148mm)
|
||||
# PAS de -o raw : le driver CUPS gère le rendu correctement
|
||||
# =============================================================================
|
||||
|
||||
# (plus de TEST_MODE — paramètres calibrés définitifs ci-dessous)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -20,23 +32,13 @@ filename="${1:-}"
|
||||
media_type="${2:-}"
|
||||
action_config_name="${3:-}"
|
||||
copies="${4:-1}"
|
||||
# Liste CSV d'imprimantes à exclure du load-balancing (optionnel, passé par JH-Photomaton)
|
||||
skip_printers="${5:-}"
|
||||
|
||||
# --- Noms des imprimantes CUPS (WiFi) ---
|
||||
PRINTER_1="Selphy_Blanche_WiFi"
|
||||
PRINTER_2="Selphy_Noire_WiFi"
|
||||
|
||||
# --- Options d'impression Selphy ---
|
||||
# Mode raw : envoie le JPEG directement a la Selphy
|
||||
# Postcard.Fullbleed : impression sans bordures
|
||||
PRINT_OPTIONS="-o media=Postcard.Fullbleed -o raw"
|
||||
|
||||
# --- Calibrage bordures pour impression Fullbleed ---
|
||||
# La Selphy deborde de quelques mm en mode Fullbleed
|
||||
# On ajoute des marges noires pour compenser et eviter de couper la photo
|
||||
# Valeurs calibrees pour Canon Selphy CP1300 via WiFi
|
||||
PRINT_EXTENT="2114x1418"
|
||||
PRINT_ROLL="-12+8"
|
||||
|
||||
# --- Logging ---
|
||||
LOG_TAG="photomaton-print"
|
||||
log_info() { logger -t "$LOG_TAG" "[INFO] $1"; echo "[INFO] $1"; }
|
||||
@@ -59,80 +61,165 @@ if [ "$copies" -lt 1 ] || [ "$copies" -gt 3 ]; then
|
||||
fi
|
||||
|
||||
# --- Preparation de l'image pour impression ---
|
||||
# Resize a 2000x1333 (ratio 3:2), ajout de marges noires pour compenser
|
||||
# le debordement Fullbleed, puis decalage pour centrer correctement
|
||||
filename_no_ext="${filename%.jpg}"
|
||||
filename_print="${filename_no_ext}_print.jpg"
|
||||
|
||||
if command -v convert &>/dev/null; then
|
||||
log_info "Preparation de l'image pour impression Fullbleed..."
|
||||
if ! command -v convert &>/dev/null; then
|
||||
log_error "ImageMagick (convert) non installe — impression de l'original"
|
||||
FILE_TO_PRINT="$filename"
|
||||
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
|
||||
else
|
||||
log_info "Preparation image pour impression borderless..."
|
||||
if convert "$filename" \
|
||||
-resize 2000x1333 \
|
||||
-background black \
|
||||
-background white \
|
||||
-gravity center \
|
||||
-extent ${PRINT_EXTENT} \
|
||||
-roll ${PRINT_ROLL} \
|
||||
-extent 2114x1418 \
|
||||
-roll -12+8 \
|
||||
-quality 95 \
|
||||
"$filename_print" 2>/dev/null; then
|
||||
FILE_TO_PRINT="$filename_print"
|
||||
log_info "Image preparee: $filename_print"
|
||||
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
|
||||
log_info "Image preparee: 2114x1418 (overscan+roll) → $filename_print"
|
||||
else
|
||||
log_error "Echec de la preparation, impression de l'original"
|
||||
log_error "Echec convert — impression de l'original"
|
||||
FILE_TO_PRINT="$filename"
|
||||
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
|
||||
fi
|
||||
else
|
||||
log_error "ImageMagick (convert) non installe, impression de l'original"
|
||||
FILE_TO_PRINT="$filename"
|
||||
fi
|
||||
|
||||
# --- Selection de l'imprimante (load balancing) ---
|
||||
printer_1_status=$(lpstat -p "$PRINTER_1" 2>/dev/null || echo "not found")
|
||||
printer_2_status=$(lpstat -p "$PRINTER_2" 2>/dev/null || echo "not found")
|
||||
|
||||
# Verifie si une imprimante est dans la liste skip_printers (CSV)
|
||||
is_skipped() { [[ ",$skip_printers," == *",$1,"* ]]; }
|
||||
|
||||
# Une imprimante est utilisable si CUPS la connait ET qu'elle n'est pas exclue
|
||||
p1_ok=false
|
||||
p2_ok=false
|
||||
[[ "$printer_1_status" != "not found" ]] && ! is_skipped "$PRINTER_1" && p1_ok=true
|
||||
[[ "$printer_2_status" != "not found" ]] && ! is_skipped "$PRINTER_2" && p2_ok=true
|
||||
|
||||
SELECTED_PRINTER=""
|
||||
|
||||
if [[ "$printer_1_status" == *"idle"* ]]; then
|
||||
if $p1_ok && [[ "$printer_1_status" == *"idle"* ]]; then
|
||||
SELECTED_PRINTER="$PRINTER_1"
|
||||
log_info "Imprimante selectionnee: $PRINTER_1 (idle)"
|
||||
elif [[ "$printer_2_status" == *"idle"* ]]; then
|
||||
elif $p2_ok && [[ "$printer_2_status" == *"idle"* ]]; then
|
||||
SELECTED_PRINTER="$PRINTER_2"
|
||||
log_info "Imprimante selectionnee: $PRINTER_2 (idle)"
|
||||
else
|
||||
# Aucune idle : choisir celle avec le moins de jobs en attente
|
||||
elif $p1_ok && $p2_ok; then
|
||||
# Les deux sont accessibles mais aucune n'est idle — load balance sur les jobs
|
||||
jobs_1=$(lpstat -o "$PRINTER_1" 2>/dev/null | wc -l)
|
||||
jobs_2=$(lpstat -o "$PRINTER_2" 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$jobs_1" -le "$jobs_2" ] 2>/dev/null; then
|
||||
SELECTED_PRINTER="$PRINTER_1"
|
||||
else
|
||||
SELECTED_PRINTER="$PRINTER_2"
|
||||
fi
|
||||
log_info "Aucune imprimante idle, file d'attente sur: $SELECTED_PRINTER (jobs: P1=$jobs_1, P2=$jobs_2)"
|
||||
log_info "Load balancing: $SELECTED_PRINTER (P1=$jobs_1 jobs, P2=$jobs_2 jobs)"
|
||||
elif $p1_ok; then
|
||||
SELECTED_PRINTER="$PRINTER_1"
|
||||
log_info "Seule imprimante disponible: $PRINTER_1 (P2 inaccessible ou exclue)"
|
||||
elif $p2_ok; then
|
||||
SELECTED_PRINTER="$PRINTER_2"
|
||||
log_info "Seule imprimante disponible: $PRINTER_2 (P1 inaccessible ou exclue)"
|
||||
else
|
||||
log_error "Aucune imprimante accessible dans CUPS"
|
||||
echo "PRINT_ERROR::no_printer_available"
|
||||
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Impression ---
|
||||
log_info "Impression x${copies} sur $SELECTED_PRINTER: $FILE_TO_PRINT"
|
||||
|
||||
if lp -n "$copies" -d "$SELECTED_PRINTER" $PRINT_OPTIONS "$FILE_TO_PRINT"; then
|
||||
log_info "Job d'impression envoye avec succes"
|
||||
|
||||
# Sortie parsable par Node-RED pour les compteurs
|
||||
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
|
||||
|
||||
# Nettoyage du fichier temporaire (apres que CUPS l'ait lu)
|
||||
if [ "$FILE_TO_PRINT" = "$filename_print" ] && [ -f "$filename_print" ]; then
|
||||
(sleep 30 && rm -f "$filename_print" 2>/dev/null) &
|
||||
fi
|
||||
|
||||
exit 0
|
||||
else
|
||||
log_error "Echec de l'envoi du job d'impression"
|
||||
echo "PRINT_ERROR:${SELECTED_PRINTER}"
|
||||
|
||||
# Nettoyage meme en cas d'erreur
|
||||
if [ -f "$filename_print" ]; then
|
||||
rm -f "$filename_print" 2>/dev/null
|
||||
fi
|
||||
job_output=$(lp -n "$copies" -d "$SELECTED_PRINTER" $PRINT_OPTIONS "$FILE_TO_PRINT" 2>&1)
|
||||
lp_exit=$?
|
||||
|
||||
if [ $lp_exit -ne 0 ]; then
|
||||
log_error "lp a echoue (exit $lp_exit): $job_output"
|
||||
echo "PRINT_ERROR:${SELECTED_PRINTER}:lp_failed"
|
||||
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extraction du job ID (ex: "request id is Selphy_Blanche_WiFi-42 (1 file(s))")
|
||||
JOB_ID=$(echo "$job_output" | grep -oE '[A-Za-z0-9_-]+-[0-9]+' | head -1)
|
||||
log_info "Job CUPS accepte: ${JOB_ID:-inconnu}"
|
||||
|
||||
if [ -z "$JOB_ID" ]; then
|
||||
log_info "Job ID non extrait — impression reputee reussie"
|
||||
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
|
||||
[ "$FILE_TO_PRINT" = "$filename_print" ] && (sleep 30 && rm -f "$filename_print" 2>/dev/null) &
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Sondage IPP direct (Selphy CP1300 port 631, sans auth) ---
|
||||
PRINTER_IP=$(lpstat -v "$SELECTED_PRINTER" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
IPP_URI=""
|
||||
IPP_TEST="/usr/share/cups/ipptool/get-printer-attributes.test"
|
||||
if [ -n "$PRINTER_IP" ] && command -v ipptool &>/dev/null && [ -f "$IPP_TEST" ]; then
|
||||
IPP_URI="ipp://${PRINTER_IP}:631/ipp/print"
|
||||
log_info "Sondage IPP direct actif: $IPP_URI"
|
||||
else
|
||||
log_info "Sondage IPP desactive (IP=${PRINTER_IP:-inconnu})"
|
||||
fi
|
||||
|
||||
IPP_ERROR_REASONS="input-tray-missing|media-empty|media-needed|marker-supply-empty|cover-open|door-open|offline-report"
|
||||
|
||||
# --- Suivi du job CUPS ---
|
||||
MAX_WAIT=90
|
||||
POLL_INTERVAL=3
|
||||
elapsed=0
|
||||
|
||||
log_info "Suivi du job (max ${MAX_WAIT}s)..."
|
||||
|
||||
while [ $elapsed -lt $MAX_WAIT ]; do
|
||||
sleep $POLL_INTERVAL
|
||||
elapsed=$((elapsed + POLL_INTERVAL))
|
||||
|
||||
# 1. Succes CUPS
|
||||
if lpstat -W completed 2>/dev/null | grep -q "^${JOB_ID} "; then
|
||||
log_info "Job $JOB_ID termine avec succes (${elapsed}s)"
|
||||
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
|
||||
[ "$FILE_TO_PRINT" = "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Erreur IPP directe (papier absent, capot ouvert, etc.)
|
||||
if [ -n "$IPP_URI" ]; then
|
||||
ipp_reasons=$(ipptool -tv "$IPP_URI" "$IPP_TEST" 2>/dev/null \
|
||||
| grep "printer-state-reasons" | head -1)
|
||||
if echo "$ipp_reasons" | grep -qiE "$IPP_ERROR_REASONS"; then
|
||||
reason=$(echo "$ipp_reasons" | grep -oiE "$IPP_ERROR_REASONS" | head -1)
|
||||
log_error "Erreur imprimante via IPP: $reason (${elapsed}s)"
|
||||
cancel "$JOB_ID" 2>/dev/null || true
|
||||
echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason}"
|
||||
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Fallback CUPS stopped/disabled
|
||||
printer_state=$(lpstat -p "$SELECTED_PRINTER" 2>/dev/null || true)
|
||||
if echo "$printer_state" | grep -qiE "stopped|disabled"; then
|
||||
reason=$(lpstat -l -p "$SELECTED_PRINTER" 2>/dev/null \
|
||||
| grep -i "Reason:" | head -1 \
|
||||
| sed 's/.*Reason:[[:space:]]*//' | xargs)
|
||||
log_error "Imprimante stoppee CUPS: ${reason:-stopped} (${elapsed}s)"
|
||||
cancel "$JOB_ID" 2>/dev/null || true
|
||||
echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason:-stopped}"
|
||||
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Impression en cours... (${elapsed}s / ${MAX_WAIT}s)"
|
||||
done
|
||||
|
||||
log_error "Timeout impression apres ${MAX_WAIT}s (job: $JOB_ID)"
|
||||
cancel "$JOB_ID" 2>/dev/null || true
|
||||
echo "PRINT_ERROR:${SELECTED_PRINTER}:timeout_${MAX_WAIT}s"
|
||||
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
|
||||
exit 1
|
||||
|
||||
+233
-57
@@ -1,110 +1,188 @@
|
||||
"""API de gestion des actions photobooth-app + mapping bouton."""
|
||||
"""API de gestion des actions photobooth-app + mapping bouton + presets."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ── Action par défaut ────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_ACTION: dict = {
|
||||
"name": "Nouvelle action",
|
||||
"jobcontrol": {"countdown_capture": 5.0},
|
||||
"processing": {
|
||||
"remove_background": False,
|
||||
"fill_background_enable": False,
|
||||
"fill_background_color": "#ededed",
|
||||
"img_background_enable": False,
|
||||
"img_background_file": None,
|
||||
"image_filter": "original",
|
||||
"img_frame_enable": False,
|
||||
"img_frame_file": None,
|
||||
"texts_enable": False,
|
||||
"texts": [],
|
||||
},
|
||||
"trigger": {
|
||||
"ui_trigger": {
|
||||
"show_button": False,
|
||||
"title": "Nouvelle action",
|
||||
"icon": "Photo",
|
||||
"use_custom_color": False,
|
||||
"custom_color": "#016911",
|
||||
},
|
||||
"keyboard_trigger": {"keycode": ""},
|
||||
"gpio_trigger": {"pin": "", "trigger_on": "pressed"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _presets_dir(request: Request) -> Path:
|
||||
base = Path(request.app.state.config_service.path).parent.parent / "data" / "action_presets"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
# ── Lecture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/actions/photobooth")
|
||||
async def get_pb_actions(request: Request):
|
||||
"""Retourne les actions image de photobooth-app + le mapping bouton actuel."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
# Enrichit avec l'index
|
||||
for i, action in enumerate(actions):
|
||||
action["_index"] = i
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
}
|
||||
return {"actions": actions, "button_mapping": cfg.button_actions}
|
||||
|
||||
|
||||
@router.get("/actions/assets")
|
||||
async def get_assets(request: Request):
|
||||
"""Liste les cadres et fonds disponibles dans userdata."""
|
||||
pb = request.app.state.photobooth_service
|
||||
frames = await pb.list_userdata_frames()
|
||||
backgrounds = await pb.list_userdata_backgrounds()
|
||||
return {"frames": frames, "backgrounds": backgrounds}
|
||||
|
||||
|
||||
@router.put("/actions/mapping")
|
||||
async def update_button_mapping(
|
||||
request: Request,
|
||||
mapping: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Met à jour le mapping clics → actions.
|
||||
Body: { "1": {"label": "...", "photobooth_index": 0}, ... }
|
||||
"""
|
||||
config_svc = request.app.state.config_service
|
||||
# ── CRUD actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Validation basique
|
||||
for k, v in mapping.items():
|
||||
if not isinstance(v, dict) or "photobooth_index" not in v:
|
||||
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
|
||||
@router.post("/actions/photobooth")
|
||||
async def create_action(request: Request, body: dict = Body(default={})):
|
||||
"""Crée une nouvelle action vide."""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.setdefault("actions", {}).setdefault("image", [])
|
||||
new_action = copy.deepcopy(DEFAULT_ACTION)
|
||||
if body.get("name"):
|
||||
new_action["name"] = body["name"]
|
||||
actions.append(new_action)
|
||||
await pb.write_pb_config(pb_config)
|
||||
idx = len(actions) - 1
|
||||
logger.info("Nouvelle action créée: index %d, nom=%s", idx, new_action["name"])
|
||||
return {"ok": True, "index": idx, "action": new_action}
|
||||
|
||||
config_svc.save_button_actions(mapping)
|
||||
request.app.state.config.button_actions = mapping
|
||||
|
||||
# Met aussi à jour le button_service
|
||||
btn = request.app.state.button_service
|
||||
btn._btn_actions = mapping
|
||||
@router.post("/actions/photobooth/{index}/clone")
|
||||
async def clone_action(request: Request, index: int):
|
||||
"""Clone une action (insérée juste après l'original)."""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
if index < 0 or index >= len(actions):
|
||||
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
|
||||
cloned = copy.deepcopy(actions[index])
|
||||
cloned.pop("_index", None)
|
||||
cloned["name"] = f"{cloned.get('name', 'Action')} (copie)"
|
||||
actions.insert(index + 1, cloned)
|
||||
await pb.write_pb_config(pb_config)
|
||||
logger.info("Action %d clonée → %d: %s", index, index + 1, cloned["name"])
|
||||
return {"ok": True, "index": index + 1, "action": cloned}
|
||||
|
||||
logger.info("Mapping bouton mis à jour: %s", mapping)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
@router.delete("/actions/photobooth/{index}")
|
||||
async def delete_action(request: Request, index: int):
|
||||
"""Supprime une action."""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
if index < 0 or index >= len(actions):
|
||||
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
|
||||
if len(actions) <= 1:
|
||||
return JSONResponse({"error": "Impossible de supprimer la dernière action"}, status_code=400)
|
||||
removed = actions.pop(index)
|
||||
await pb.write_pb_config(pb_config)
|
||||
logger.info("Action %d supprimée: %s", index, removed.get("name"))
|
||||
return {"ok": True, "removed_name": removed.get("name")}
|
||||
|
||||
|
||||
@router.put("/actions/photobooth/reorder")
|
||||
async def reorder_actions(request: Request, order: list = Body(...)):
|
||||
"""Réordonne les actions. Body: [ancien_idx_0, ancien_idx_1, …]"""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
if sorted(order) != list(range(len(actions))):
|
||||
return JSONResponse({"error": "Ordre invalide"}, status_code=400)
|
||||
pb_config["actions"]["image"] = [actions[i] for i in order]
|
||||
await pb.write_pb_config(pb_config)
|
||||
logger.info("Actions réordonnées: %s", order)
|
||||
return {"ok": True, "order": order}
|
||||
|
||||
|
||||
@router.put("/actions/photobooth/{index}")
|
||||
async def update_pb_action(
|
||||
request: Request,
|
||||
index: int,
|
||||
updates: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.)
|
||||
updates peut contenir: countdown_capture, img_frame_file, img_background_file,
|
||||
remove_background, image_filter, name
|
||||
async def update_pb_action(request: Request, index: int, updates: dict = Body(...)):
|
||||
"""Met à jour une action (tous les champs sauf GPIO/keyboard trigger).
|
||||
|
||||
Champs plats acceptés:
|
||||
name, countdown_capture,
|
||||
img_frame_enable, img_frame_file,
|
||||
img_background_enable, img_background_file,
|
||||
fill_background_enable, fill_background_color,
|
||||
remove_background, image_filter, texts_enable,
|
||||
ui_show_button, ui_title, ui_icon, ui_use_custom_color, ui_custom_color
|
||||
"""
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
if index < 0 or index >= len(actions):
|
||||
return JSONResponse({"error": f"Index {index} invalide (max: {len(actions)-1})"}, status_code=400)
|
||||
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
|
||||
|
||||
action = actions[index]
|
||||
|
||||
# Mise à jour des champs autorisés
|
||||
allowed_root = {"name"}
|
||||
allowed_processing = {"remove_background", "img_frame_file", "img_background_file",
|
||||
"image_filter", "fill_background_enable", "fill_background_color",
|
||||
"img_background_enable", "texts_enable"}
|
||||
allowed_jobcontrol = {"countdown_capture"}
|
||||
if "name" in updates:
|
||||
action["name"] = updates["name"]
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in allowed_root:
|
||||
action[key] = value
|
||||
elif key in allowed_processing:
|
||||
action.setdefault("processing", {})[key] = value
|
||||
elif key in allowed_jobcontrol:
|
||||
action.setdefault("jobcontrol", {})[key] = value
|
||||
if "countdown_capture" in updates:
|
||||
action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"])
|
||||
|
||||
proc = action.setdefault("processing", {})
|
||||
for key in ("img_frame_enable", "img_frame_file",
|
||||
"img_background_enable", "img_background_file",
|
||||
"fill_background_enable", "fill_background_color",
|
||||
"remove_background", "image_filter", "texts_enable"):
|
||||
if key in updates:
|
||||
proc[key] = updates[key]
|
||||
|
||||
ui = action.setdefault("trigger", {}).setdefault("ui_trigger", {})
|
||||
for key in ("show_button", "title", "icon", "use_custom_color", "custom_color"):
|
||||
if f"ui_{key}" in updates:
|
||||
ui[key] = updates[f"ui_{key}"]
|
||||
|
||||
pb_config["actions"]["image"][index] = action
|
||||
await pb.write_pb_config(pb_config)
|
||||
|
||||
logger.info("Action %d mise à jour: %s", index, list(updates.keys()))
|
||||
return {"ok": True, "index": index, "action": action}
|
||||
|
||||
|
||||
# ── Déclenchement ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/actions/trigger/{index}")
|
||||
async def trigger_action(request: Request, index: int):
|
||||
"""Déclenche une action directement (test)."""
|
||||
@@ -119,3 +197,101 @@ async def trigger_action(request: Request, index: int):
|
||||
led.play("error")
|
||||
btn.relay_on()
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# ── Mapping bouton ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/actions/mapping")
|
||||
async def update_button_mapping(request: Request, mapping: dict = Body(...)):
|
||||
"""Body: { "1": {"label": "…", "photobooth_index": 0}, … }"""
|
||||
config_svc = request.app.state.config_service
|
||||
for k, v in mapping.items():
|
||||
if not isinstance(v, dict) or "photobooth_index" not in v:
|
||||
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
|
||||
config_svc.save_button_actions(mapping)
|
||||
request.app.state.config.button_actions = mapping
|
||||
request.app.state.button_service._btn_actions = mapping
|
||||
logger.info("Mapping bouton mis à jour: %s", mapping)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
# ── Presets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/actions/presets")
|
||||
async def list_presets(request: Request):
|
||||
pdir = _presets_dir(request)
|
||||
presets = []
|
||||
for f in sorted(pdir.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
presets.append({
|
||||
"name": f.stem,
|
||||
"label": data.get("label", f.stem),
|
||||
"description": data.get("description", ""),
|
||||
"action_count": len(data.get("actions", [])),
|
||||
"saved_at": data.get("saved_at", ""),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"presets": presets}
|
||||
|
||||
|
||||
@router.post("/actions/presets")
|
||||
async def save_preset(request: Request, body: dict = Body(...)):
|
||||
"""Sauvegarde les actions actuelles comme preset."""
|
||||
label = body.get("name", "").strip()
|
||||
description = body.get("description", "").strip()
|
||||
if not label:
|
||||
return JSONResponse({"error": "Nom requis"}, status_code=400)
|
||||
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", label).strip("_")
|
||||
if not safe_name:
|
||||
return JSONResponse({"error": "Nom invalide"}, status_code=400)
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
clean = [{k: v for k, v in copy.deepcopy(a).items() if k != "_index"} for a in actions]
|
||||
|
||||
preset = {
|
||||
"label": label,
|
||||
"description": description,
|
||||
"saved_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"actions": clean,
|
||||
}
|
||||
(_presets_dir(request) / f"{safe_name}.json").write_text(
|
||||
json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
logger.info("Preset sauvegardé: %s (%d actions)", safe_name, len(clean))
|
||||
return {"ok": True, "name": safe_name, "action_count": len(clean)}
|
||||
|
||||
|
||||
@router.post("/actions/presets/{name}/restore")
|
||||
async def restore_preset(request: Request, name: str):
|
||||
"""Restaure un preset (remplace les actions actuelles)."""
|
||||
path = _presets_dir(request) / f"{name}.json"
|
||||
if not path.exists():
|
||||
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
|
||||
try:
|
||||
preset = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Lecture preset: {e}"}, status_code=500)
|
||||
actions = preset.get("actions", [])
|
||||
if not actions:
|
||||
return JSONResponse({"error": "Preset vide"}, status_code=400)
|
||||
pb = request.app.state.photobooth_service
|
||||
pb_config = await pb.read_pb_config()
|
||||
pb_config.setdefault("actions", {})["image"] = actions
|
||||
await pb.write_pb_config(pb_config)
|
||||
logger.info("Preset restauré: %s (%d actions)", name, len(actions))
|
||||
return {"ok": True, "name": name, "action_count": len(actions)}
|
||||
|
||||
|
||||
@router.delete("/actions/presets/{name}")
|
||||
async def delete_preset(request: Request, name: str):
|
||||
path = _presets_dir(request) / f"{name}.json"
|
||||
if not path.exists():
|
||||
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
|
||||
path.unlink()
|
||||
logger.info("Preset supprimé: %s", name)
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
+18
-10
@@ -1,6 +1,7 @@
|
||||
"""Routes du dashboard admin -- authentification requise."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
@@ -95,17 +96,8 @@ async def admin_actions(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
return _templates.TemplateResponse(request, "admin/actions.html", {
|
||||
"config": cfg,
|
||||
"pb_actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
})
|
||||
return _templates.TemplateResponse(request, "admin/actions.html", {"config": cfg})
|
||||
|
||||
|
||||
@router.get("/admin/settings", response_class=HTMLResponse)
|
||||
@@ -120,6 +112,15 @@ async def admin_settings(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/admin/events", response_class=HTMLResponse)
|
||||
async def admin_events(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
cfg = request.app.state.config
|
||||
return _templates.TemplateResponse(request, "admin/events.html", {"config": cfg})
|
||||
|
||||
|
||||
@router.get("/admin/print", response_class=HTMLResponse)
|
||||
async def admin_print(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
@@ -132,6 +133,13 @@ async def admin_print(request: Request):
|
||||
queue = await printer_svc.get_queue()
|
||||
printers = await printer_svc.get_printers_status()
|
||||
|
||||
# Formate les dates pour le template Jinja2
|
||||
for q in queue:
|
||||
ts = q.get("requested_at")
|
||||
q["requested_at_str"] = (
|
||||
datetime.fromtimestamp(ts).strftime("%d/%m %H:%M") if ts else "—"
|
||||
)
|
||||
|
||||
return _templates.TemplateResponse(request, "admin/print.html", {
|
||||
"config": cfg,
|
||||
"queue": queue,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""API galerie admin — impression et suppression de photos."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -20,20 +22,6 @@ 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) -> Path | None:
|
||||
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
|
||||
base = Path(media_dir)
|
||||
if not base.exists():
|
||||
return None
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
f = base / f"{photo_id}{ext}"
|
||||
if f.exists():
|
||||
return f
|
||||
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
|
||||
@@ -46,6 +34,9 @@ async def admin_get_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
event_slug: str = Query(default=None),
|
||||
date_from: float = Query(default=None),
|
||||
date_to: float = Query(default=None),
|
||||
):
|
||||
"""Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
|
||||
if not _require_auth(request):
|
||||
@@ -53,37 +44,74 @@ async def admin_get_photos(
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
printer_svc = request.app.state.printer_service
|
||||
cfg = request.app.state.config
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
|
||||
# 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)]
|
||||
|
||||
# Enrichit chaque photo avec son timestamp de création
|
||||
# photobooth-app retourne 'created_at' en UTC (ex: "2026-07-17T14:15:52")
|
||||
for p in photos:
|
||||
pid = _get_id(p)
|
||||
p["photo_id"] = pid
|
||||
created_str = p.get("created_at", "")
|
||||
try:
|
||||
# Interprète created_at comme UTC → timestamp Unix correct
|
||||
dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
mt = dt.timestamp()
|
||||
except (ValueError, TypeError):
|
||||
mt = 0.0
|
||||
p["mtime"] = mt
|
||||
# Date pour affichage : convertit UTC → heure locale
|
||||
p["date_iso"] = datetime.fromtimestamp(mt).strftime("%Y-%m-%d") if mt else ""
|
||||
p["date_label"] = datetime.fromtimestamp(mt).strftime("%d/%m/%Y %H:%M") if mt else ""
|
||||
|
||||
# Filtre par événement
|
||||
if event_slug and event_svc:
|
||||
ev = await event_svc.get_event_by_slug(event_slug)
|
||||
if ev:
|
||||
t0 = ev["started_at"] or 0.0
|
||||
t1 = ev["ended_at"] or datetime.now().timestamp()
|
||||
photos = [p for p in photos if t0 <= p["mtime"] <= t1]
|
||||
|
||||
# Filtre par plage de dates
|
||||
if date_from is not None:
|
||||
photos = [p for p in photos if p["mtime"] >= date_from]
|
||||
if date_to is not None:
|
||||
photos = [p for p in photos if p["mtime"] <= date_to]
|
||||
|
||||
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
|
||||
pid = p["photo_id"]
|
||||
p["full_url"] = pb.media_url(pid)
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
p["download_url"] = f"/api/gallery/download/{pid}"
|
||||
|
||||
# Croise avec la file d'impression en attente (une seule requête SQLite)
|
||||
# Croise avec les stats d'impression (pending, printing, done)
|
||||
try:
|
||||
pending_map = await printer_svc.get_pending_by_photo_id()
|
||||
stats_map = await printer_svc.get_print_stats_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"])
|
||||
info = stats_map.get(pid, {})
|
||||
p["print_requests"] = info.get("requests", [])
|
||||
p["print_pending"] = info.get("pending", 0)
|
||||
p["print_printing"] = info.get("printing", 0)
|
||||
p["print_done"] = info.get("done", 0)
|
||||
p["print_done_copies"] = info.get("copies_done", 0)
|
||||
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
|
||||
p["print_requests"] = []
|
||||
p["print_pending"] = 0
|
||||
p["print_printing"] = 0
|
||||
p["print_done"] = 0
|
||||
p["print_done_copies"] = 0
|
||||
|
||||
return {
|
||||
"photos": page_photos,
|
||||
@@ -116,12 +144,24 @@ async def admin_print_photo(
|
||||
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)
|
||||
# Récupère les détails de la photo via l'API photobooth-app
|
||||
# (le champ 'processed' donne le chemin relatif réel : media/processed_full/YYYYMMDD-xxx.jpg)
|
||||
item = await pb.get_media_item(photo_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": f"Photo introuvable: {photo_id}"}, status_code=404)
|
||||
|
||||
file_path = pb.media_file_path(item)
|
||||
if not file_path or not file_path.exists():
|
||||
logger.error("Fichier manquant pour %s: %s", photo_id, file_path)
|
||||
return JSONResponse(
|
||||
{"error": f"Fichier manquant sur le disque: {file_path}"}, status_code=404
|
||||
)
|
||||
|
||||
filename = file_path
|
||||
logger.info("Fichier pour impression: %s → %s", photo_id, filename)
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
|
||||
entry = await printer_svc.add_request(str(filename), thumb_url, copies, photo_id=photo_id)
|
||||
|
||||
should_print_now = (cfg.print.mode == "direct") or immediate
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.services.event_service import slugify
|
||||
@@ -59,9 +63,9 @@ async def update_event(request: Request, payload: EventUpdate):
|
||||
|
||||
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)
|
||||
if payload.new_event:
|
||||
# Archive TOUS les événements encore ouverts (ended_at IS NULL)
|
||||
await event_svc.archive_all_open()
|
||||
|
||||
# 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)
|
||||
@@ -81,6 +85,55 @@ async def update_event(request: Request, payload: EventUpdate):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/event/{slug}/export")
|
||||
async def export_event_zip(request: Request, slug: str):
|
||||
"""Exporte toutes les photos d'un événement dans un fichier ZIP.
|
||||
|
||||
Les photos sont sélectionnées par timestamp de fichier (mtime) entre
|
||||
started_at et ended_at de l'événement. Pour l'événement en cours,
|
||||
ended_at = maintenant.
|
||||
"""
|
||||
event_svc = request.app.state.event_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
ev = await event_svc.get_event_by_slug(slug)
|
||||
if not ev:
|
||||
return JSONResponse({"error": f"Événement '{slug}' introuvable"}, status_code=404)
|
||||
|
||||
started_at = ev["started_at"] or 0.0
|
||||
ended_at = ev["ended_at"] or datetime.now().timestamp()
|
||||
|
||||
media_dir = Path(cfg.photobooth.media_dir)
|
||||
if not media_dir.exists():
|
||||
return JSONResponse({"error": "Dossier média introuvable"}, status_code=500)
|
||||
|
||||
# Sélectionner les photos dans la plage de l'événement
|
||||
photos = []
|
||||
for f in sorted(media_dir.iterdir()):
|
||||
if f.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||
continue
|
||||
mtime = f.stat().st_mtime
|
||||
if started_at <= mtime <= ended_at:
|
||||
photos.append(f)
|
||||
|
||||
if not photos:
|
||||
return JSONResponse({"error": "Aucune photo pour cet événement"}, status_code=404)
|
||||
|
||||
# Créer le ZIP en mémoire
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for photo in photos:
|
||||
zf.write(photo, photo.name)
|
||||
buf.seek(0)
|
||||
|
||||
filename = f"{slug}_By_LSDW_{datetime.fromtimestamp(started_at).strftime('%Y-%m-%d')}.zip"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/event/history")
|
||||
async def get_event_history(request: Request):
|
||||
"""Retourne tous les événements passés avec leurs statistiques."""
|
||||
|
||||
+64
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -34,12 +34,35 @@ async def api_gallery_photos(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=24, ge=1, le=100),
|
||||
event_slug: str = Query(default=None, description="Filtrer par slug d'événement"),
|
||||
):
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
all_photos = await pb.get_media_collection(limit=500)
|
||||
photos = [p for p in all_photos if _is_image(p)]
|
||||
|
||||
# Filtre par événement si demandé
|
||||
if event_slug:
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
if event_svc:
|
||||
ev = await event_svc.get_event_by_slug(event_slug)
|
||||
if ev:
|
||||
started_at = ev["started_at"] or 0.0
|
||||
ended_at = ev["ended_at"] or datetime.now().timestamp()
|
||||
|
||||
def _photo_ts(p: dict) -> float:
|
||||
"""Timestamp UTC de la photo depuis le champ created_at de photobooth-app."""
|
||||
created_str = p.get("created_at", "")
|
||||
try:
|
||||
return datetime.fromisoformat(created_str).replace(
|
||||
tzinfo=timezone.utc
|
||||
).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
photos = [p for p in photos if started_at <= _photo_ts(p) <= ended_at]
|
||||
|
||||
total = len(photos)
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
@@ -51,29 +74,62 @@ async def api_gallery_photos(
|
||||
p["thumb_url"] = pb.thumbnail_url(pid)
|
||||
p["download_url"] = f"/api/gallery/download/{pid}"
|
||||
|
||||
# Ajoute les stats d'impression (copies réussies) pour les badges
|
||||
printer_svc = getattr(request.app.state, "printer_service", None)
|
||||
if printer_svc:
|
||||
try:
|
||||
stats_map = await printer_svc.get_print_stats_by_photo_id()
|
||||
for p in page_photos:
|
||||
pid = _get_id(p)
|
||||
info = stats_map.get(pid, {})
|
||||
p["print_done_copies"] = info.get("copies_done", 0)
|
||||
except Exception:
|
||||
for p in page_photos:
|
||||
p["print_done_copies"] = 0
|
||||
else:
|
||||
for p in page_photos:
|
||||
p["print_done_copies"] = 0
|
||||
|
||||
return {
|
||||
"photos": page_photos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
"event_slug": event_slug,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/gallery/download/{photo_id}")
|
||||
async def download_photo(request: Request, photo_id: str):
|
||||
"""Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours."""
|
||||
"""Proxy la photo full-res avec Content-Disposition et nom basé sur l'événement + date photo."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
event_svc = getattr(request.app.state, "event_service", None)
|
||||
|
||||
img_url = pb.media_url(photo_id)
|
||||
# Toujours fetcher depuis localhost (base_url), pas l'URL publique
|
||||
local_url = f"{pb._base.rstrip('/')}/media/full/{photo_id}"
|
||||
|
||||
# Nom de l'événement (lisible) + date de la photo depuis le disque
|
||||
event_name = (cfg.event.name or cfg.event.slug or "Photomaton").replace(" ", "_")
|
||||
slug = cfg.event.slug or "photomaton"
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
filename = f"{slug}_By_LSDW_{date_str}.jpg"
|
||||
|
||||
# Tenter de récupérer la vraie date depuis le fichier
|
||||
photo_date = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
||||
try:
|
||||
media_dir = Path(cfg.photobooth.media_dir)
|
||||
candidates = list(media_dir.glob(f"{photo_id}*"))
|
||||
if candidates:
|
||||
mtime = candidates[0].stat().st_mtime
|
||||
photo_date = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d_%H-%M")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_." else "_" for c in event_name)
|
||||
filename = f"{safe_name}_{photo_date}.jpg"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.get(img_url)
|
||||
r = await client.get(local_url)
|
||||
r.raise_for_status()
|
||||
|
||||
if event_svc:
|
||||
@@ -90,8 +146,8 @@ async def download_photo(request: Request, photo_id: str):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e)
|
||||
return RedirectResponse(url=img_url)
|
||||
logger.warning("Download proxy échoue (%s): %s", photo_id, e)
|
||||
return RedirectResponse(url=pb.media_url(photo_id))
|
||||
|
||||
|
||||
def _is_image(item: dict) -> bool:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API de contrôle des LEDs WS2812b."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -103,6 +104,90 @@ async def get_effects(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/leds/ambient-lux")
|
||||
async def get_ambient_lux(request: Request):
|
||||
"""Retourne la luminosité ambiante mesurée (dernière valeur connue)."""
|
||||
led = request.app.state.led_service
|
||||
lux = led._ambient_lux
|
||||
return {
|
||||
"ambient_lux": lux,
|
||||
"known": lux >= 0,
|
||||
"auto_brightness": request.app.state.config.leds.auto_brightness,
|
||||
"description": (
|
||||
"sombre (flash plein)" if lux < 50 and lux >= 0 else
|
||||
"luminosité moyenne (flash adapté)" if lux < 180 and lux >= 0 else
|
||||
"clair (flash réduit)" if lux >= 180 else
|
||||
"non mesuré"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/leds/measure-ambient")
|
||||
async def measure_ambient_lux(request: Request):
|
||||
"""Déclenche une mesure de luminosité ambiante via snapshot liveview.
|
||||
Utile pour diagnostiquer si le flux liveview est accessible depuis JH-Photomaton.
|
||||
"""
|
||||
pb = request.app.state.photobooth_service
|
||||
led = request.app.state.led_service
|
||||
|
||||
snapshot = await pb.get_liveview_snapshot()
|
||||
if not snapshot:
|
||||
return JSONResponse({
|
||||
"ok": False,
|
||||
"error": "Aucun endpoint liveview accessible sur photobooth-app. "
|
||||
"Vérifiez que photobooth-app est démarré et que le flux vidéo est actif.",
|
||||
}, status_code=503)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(snapshot)).convert("L").resize((80, 60))
|
||||
pixels = list(img.getdata())
|
||||
avg = sum(pixels) / len(pixels)
|
||||
led.set_ambient_lux(avg)
|
||||
return {
|
||||
"ok": True,
|
||||
"ambient_lux": round(avg, 1),
|
||||
"description": (
|
||||
"sombre → flash plein (255)" if avg < 50 else
|
||||
f"moyen → flash {int(255 - (avg - 50) / 130 * 135)}" if avg < 180 else
|
||||
"clair → flash réduit (80)"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/leds/flash-timing")
|
||||
async def get_flash_timing(request: Request):
|
||||
"""Retourne les paramètres de timing du flash."""
|
||||
cfg = request.app.state.config.leds
|
||||
return {
|
||||
"pre_flash_advance": cfg.pre_flash_advance,
|
||||
"countdown_duration": cfg.countdown_duration,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/leds/flash-timing")
|
||||
async def set_flash_timing(
|
||||
request: Request,
|
||||
pre_flash_advance: float = Query(..., ge=0.0, le=3.0, description="Secondes avant fin countdown pour démarrer le flash"),
|
||||
save: bool = Query(default=True),
|
||||
):
|
||||
"""Règle combien de secondes avant la fin du countdown le flash démarre (0–3s)."""
|
||||
cfg = request.app.state.config.leds
|
||||
cfg.pre_flash_advance = pre_flash_advance
|
||||
if save:
|
||||
config_svc = request.app.state.config_service
|
||||
with open(config_svc.path, encoding="utf-8") as f:
|
||||
import yaml
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("leds", {})["pre_flash_advance"] = pre_flash_advance
|
||||
with open(config_svc.path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True, "pre_flash_advance": pre_flash_advance}
|
||||
|
||||
|
||||
@router.put("/leds/effect/{effect_name}")
|
||||
async def update_effect(
|
||||
request: Request,
|
||||
|
||||
+122
-1
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi import APIRouter, Request, Query, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -125,8 +125,129 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
|
||||
return {"ok": True, "mode": mode}
|
||||
|
||||
|
||||
@router.post("/print/user-print-config")
|
||||
async def set_user_print_config(request: Request, body: dict = Body(...)):
|
||||
"""Configure l'impression depuis la galerie utilisateur."""
|
||||
enabled = bool(body.get("user_print_enabled", False))
|
||||
quota = max(0, int(body.get("user_print_quota", 0)))
|
||||
max_copies = max(1, min(10, int(body.get("user_print_max_copies", 1))))
|
||||
config_svc = request.app.state.config_service
|
||||
config_svc.save_user_print_config(enabled, quota, max_copies)
|
||||
logger.info("User print config: enabled=%s quota=%d max_copies=%d", enabled, quota, max_copies)
|
||||
return {"ok": True, "user_print_enabled": enabled, "user_print_quota": quota, "user_print_max_copies": max_copies}
|
||||
|
||||
|
||||
@router.get("/print/user-print-config")
|
||||
async def get_user_print_config(request: Request):
|
||||
"""Retourne la config impression galerie utilisateur."""
|
||||
cfg = request.app.state.config.print
|
||||
return {
|
||||
"user_print_enabled": cfg.user_print_enabled,
|
||||
"user_print_quota": cfg.user_print_quota,
|
||||
"user_print_max_copies": cfg.user_print_max_copies,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/print/user-print-status")
|
||||
async def get_user_print_status(request: Request):
|
||||
"""Statut d'impression de la session courante (quota restant, config active)."""
|
||||
cfg = request.app.state.config.print
|
||||
count = request.session.get("gallery_print_count", 0)
|
||||
remaining = (cfg.user_print_quota - count) if cfg.user_print_quota > 0 else -1
|
||||
return {
|
||||
"user_print_enabled": cfg.user_print_enabled,
|
||||
"user_print_quota": cfg.user_print_quota,
|
||||
"user_print_max_copies": cfg.user_print_max_copies,
|
||||
"session_print_count": count,
|
||||
"remaining_quota": remaining, # -1 = illimité
|
||||
}
|
||||
|
||||
|
||||
@router.post("/print/request-gallery")
|
||||
async def gallery_print_request(request: Request, body: dict = Body(...)):
|
||||
"""Demande d'impression depuis la galerie publique.
|
||||
Vérifie que l'impression est activée et que le quota session n'est pas dépassé.
|
||||
"""
|
||||
cfg_print = request.app.state.config.print
|
||||
|
||||
# ── Activation ────────────────────────────────────────────────────────────
|
||||
if not cfg_print.user_print_enabled:
|
||||
return JSONResponse({"error": "L'impression est désactivée"}, status_code=403)
|
||||
|
||||
photo_id = str(body.get("photo_id", "")).strip()
|
||||
if not photo_id:
|
||||
return JSONResponse({"error": "photo_id requis"}, status_code=400)
|
||||
|
||||
copies = max(1, min(cfg_print.user_print_max_copies, int(body.get("copies", 1))))
|
||||
|
||||
# ── Quota session ─────────────────────────────────────────────────────────
|
||||
if cfg_print.user_print_quota > 0:
|
||||
count = request.session.get("gallery_print_count", 0)
|
||||
if count >= cfg_print.user_print_quota:
|
||||
return JSONResponse({
|
||||
"error": f"Quota atteint ({cfg_print.user_print_quota} impression(s) maximum par session)",
|
||||
"quota_exceeded": True,
|
||||
}, status_code=429)
|
||||
|
||||
# ── Résolution du fichier via l'API photobooth-app ───────────────────────
|
||||
# On passe par get_media_item() comme le fait admin_print_photo — le champ
|
||||
# "processed" contient le chemin réel sur le disque (ex: media/processed_full/xxx.jpg).
|
||||
# Un simple glob sur photo_id ne marche pas car l'id interne ≠ nom de fichier.
|
||||
pb = request.app.state.photobooth_service
|
||||
item = await pb.get_media_item(photo_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": "Photo introuvable"}, status_code=404)
|
||||
file_path = pb.media_file_path(item)
|
||||
if not file_path or not file_path.exists():
|
||||
logger.error("Fichier manquant pour %s: %s", photo_id, file_path)
|
||||
return JSONResponse({"error": "Fichier photo introuvable sur le disque"}, status_code=404)
|
||||
filepath = str(file_path)
|
||||
|
||||
# ── Enqueue ───────────────────────────────────────────────────────────────
|
||||
printer_svc = request.app.state.printer_service
|
||||
ws = request.app.state.ws_manager
|
||||
led = request.app.state.led_service
|
||||
|
||||
thumb_url = pb.thumbnail_url(photo_id)
|
||||
entry = await printer_svc.add_request(filepath, thumb_url, copies)
|
||||
await ws.broadcast({"type": "print_request", "entry": entry})
|
||||
|
||||
# ── Incrémenter compteur session ──────────────────────────────────────────
|
||||
new_count = request.session.get("gallery_print_count", 0) + 1
|
||||
request.session["gallery_print_count"] = new_count
|
||||
remaining = (cfg_print.user_print_quota - new_count) if cfg_print.user_print_quota > 0 else -1
|
||||
|
||||
# ── Stats é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"))
|
||||
|
||||
# ── LED si mode direct ────────────────────────────────────────────────────
|
||||
if request.app.state.config.print.mode == "direct":
|
||||
led.play("printing")
|
||||
|
||||
logger.info("Impression galerie: photo_id=%s copies=%d session_count=%d", photo_id, copies, new_count)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": entry["id"],
|
||||
"mode": request.app.state.config.print.mode,
|
||||
"remaining_quota": remaining,
|
||||
}
|
||||
|
||||
|
||||
# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
|
||||
|
||||
@router.post("/print/printers/{printer_name}/set-excluded")
|
||||
async def set_printer_excluded(request: Request, printer_name: str, excluded: bool = Query(...)):
|
||||
"""Exclut (ou réintègre) une imprimante du load-balancing JH-Photomaton.
|
||||
N'agit pas sur CUPS — sert uniquement à ignorer une imprimante temporairement hors ligne."""
|
||||
config_svc = request.app.state.config_service
|
||||
config_svc.save_printer_excluded(printer_name, excluded)
|
||||
logger.info("Imprimante %s : excluded=%s", printer_name, excluded)
|
||||
return {"ok": True, "printer": printer_name, "excluded": excluded}
|
||||
|
||||
|
||||
@router.post("/print/printers/{printer_name}/enable")
|
||||
async def printer_enable(request: Request, printer_name: str):
|
||||
"""Active une imprimante CUPS (cupsenable + cupsaccept)."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API de surveillance des ressources système."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Body
|
||||
@@ -9,6 +10,16 @@ from pydantic import BaseModel
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Services autorisés pour le restart individuel (jh-photomaton a son propre endpoint)
|
||||
_ALLOWED_SERVICES = {
|
||||
"photobooth-app",
|
||||
"photobooth-kiosk",
|
||||
"zoraxy",
|
||||
"hostapd",
|
||||
"dnsmasq",
|
||||
"cups",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/system/stats")
|
||||
async def system_stats(request: Request):
|
||||
@@ -68,7 +79,6 @@ async def set_delete_button(request: Request, visible: bool):
|
||||
Écrit userdata/private.css puis redémarre photobooth-app pour que le CSS
|
||||
soit pris en compte.
|
||||
"""
|
||||
import asyncio, subprocess
|
||||
pb = request.app.state.photobooth_service
|
||||
config_svc = request.app.state.config_service
|
||||
|
||||
@@ -82,7 +92,6 @@ async def set_delete_button(request: Request, visible: bool):
|
||||
|
||||
|
||||
async def _restart_photobooth():
|
||||
import asyncio
|
||||
await asyncio.sleep(0.5)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "photobooth-app.service",
|
||||
@@ -90,7 +99,75 @@ async def _restart_photobooth():
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("photobooth-app redémarré après changement CSS")
|
||||
logger.info("photobooth-app redémarré")
|
||||
|
||||
|
||||
@router.post("/system/restart-photobooth")
|
||||
async def restart_photobooth(request: Request):
|
||||
"""Redémarre photobooth-app.service pour recharger la config (actions, etc.)."""
|
||||
asyncio.create_task(_restart_photobooth())
|
||||
return {"ok": True, "message": "Redémarrage de photobooth-app en cours…"}
|
||||
|
||||
|
||||
# ── Gestion services & système ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/system/ping")
|
||||
async def ping():
|
||||
"""Endpoint de health-check — utilisé par le polling de reconnexion côté client."""
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/system/service/{name}/restart")
|
||||
async def restart_service(name: str):
|
||||
"""Redémarre un service systemd autorisé."""
|
||||
if name not in _ALLOWED_SERVICES:
|
||||
return JSONResponse({"ok": False, "error": f"Service '{name}' non autorisé"}, status_code=403)
|
||||
asyncio.create_task(_run_systemctl_restart(name))
|
||||
return {"ok": True, "message": f"Redémarrage de {name} en cours…"}
|
||||
|
||||
|
||||
async def _run_systemctl_restart(name: str):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", f"{name}.service",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("%s redémarré (exit %d)", name, proc.returncode)
|
||||
|
||||
|
||||
@router.post("/system/restart-self")
|
||||
async def restart_self():
|
||||
"""Redémarre le service jh-photomaton lui-même (avec délai pour que la réponse parte d'abord)."""
|
||||
asyncio.create_task(_restart_self_delayed())
|
||||
return {"ok": True, "message": "Redémarrage de JH-Photomaton en cours…"}
|
||||
|
||||
|
||||
async def _restart_self_delayed():
|
||||
await asyncio.sleep(1.5)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "jh-photomaton.service",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
|
||||
@router.post("/system/reboot")
|
||||
async def reboot_pi():
|
||||
"""Redémarre le Raspberry Pi (avec délai pour que la réponse parte d'abord)."""
|
||||
asyncio.create_task(_reboot_delayed())
|
||||
return {"ok": True, "message": "Redémarrage du Pi en cours…"}
|
||||
|
||||
|
||||
async def _reboot_delayed():
|
||||
await asyncio.sleep(2)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "reboot",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
|
||||
@router.post("/system/screen/refresh")
|
||||
@@ -101,7 +178,7 @@ async def screen_refresh(request: Request):
|
||||
# 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_wtype = f"WAYLAND_DISPLAY=wayland-0 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]:
|
||||
|
||||
+28
-5
@@ -8,8 +8,6 @@ from fastapi import APIRouter, Request, Query
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_COUNTDOWN = 5.0
|
||||
|
||||
|
||||
@router.get("/webhook/photobooth")
|
||||
async def photobooth_webhook(
|
||||
@@ -20,19 +18,36 @@ async def photobooth_webhook(
|
||||
led = request.app.state.led_service
|
||||
btn = request.app.state.button_service
|
||||
ws = request.app.state.ws_manager
|
||||
cfg = request.app.state.config.leds
|
||||
|
||||
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":
|
||||
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
||||
if request.app.state.config.leds.auto_brightness:
|
||||
led.play("countdown", countdown_duration=cfg.countdown_duration)
|
||||
|
||||
# Annuler le pre-flash précédent si toujours en attente
|
||||
prev = getattr(request.app.state, "pre_flash_task", None)
|
||||
if prev and not prev.done():
|
||||
prev.cancel()
|
||||
|
||||
# Déclencher le flash X secondes avant la fin du countdown
|
||||
delay = max(0.0, cfg.countdown_duration - cfg.pre_flash_advance)
|
||||
task = asyncio.create_task(_delayed_flash(led, delay))
|
||||
request.app.state.pre_flash_task = task
|
||||
logger.info(
|
||||
"Pre-flash schedulé dans %.2fs (countdown=%.1fs advance=%.1fs)",
|
||||
delay, cfg.countdown_duration, cfg.pre_flash_advance,
|
||||
)
|
||||
|
||||
if cfg.auto_brightness:
|
||||
pb = request.app.state.photobooth_service
|
||||
asyncio.create_task(_analyze_ambient_lux(led, pb))
|
||||
|
||||
case "capture":
|
||||
led.play("capture")
|
||||
# Flash déjà lancé par le pre-flash depuis counting ; ne pas redémarrer
|
||||
logger.debug("Event capture recu (pre-flash déjà actif)")
|
||||
|
||||
case "captured":
|
||||
led.play("captured")
|
||||
@@ -57,6 +72,14 @@ async def photobooth_webhook(
|
||||
return {"ok": True, "event": event_key}
|
||||
|
||||
|
||||
async def _delayed_flash(led, delay: float):
|
||||
"""Attend `delay` secondes puis déclenche l'effet flash."""
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
led.play("capture")
|
||||
logger.info("Pre-flash déclenché (délai=%.2fs)", delay)
|
||||
|
||||
|
||||
async def _delayed_relay_on(btn, delay: float):
|
||||
await asyncio.sleep(delay)
|
||||
if btn:
|
||||
|
||||
@@ -64,6 +64,8 @@ class LEDConfig:
|
||||
dma: int = 10
|
||||
strip_type: str = "WS2812"
|
||||
effects: dict = field(default_factory=dict)
|
||||
countdown_duration: float = 3.0 # Durée du countdown photobooth-app (secondes)
|
||||
pre_flash_advance: float = 0.5 # Démarrer le flash X sec avant la fin du countdown
|
||||
|
||||
def get_effect(self, name: str) -> LEDEffectConfig:
|
||||
raw = self.effects.get(name, {})
|
||||
@@ -83,6 +85,10 @@ class PrintConfig:
|
||||
script_path: str = "/home/pi/photobooth-data/script/script_print.sh"
|
||||
default_copies: int = 1
|
||||
printers: list = field(default_factory=list)
|
||||
# Impression depuis la galerie utilisateur
|
||||
user_print_enabled: bool = False
|
||||
user_print_quota: int = 0 # 0 = illimité
|
||||
user_print_max_copies: int = 1 # copies max par demande
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,13 +134,13 @@ class ConfigService:
|
||||
cfg = Config()
|
||||
|
||||
if "app" in raw:
|
||||
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if hasattr(AppConfig, k)})
|
||||
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if k in AppConfig.__dataclass_fields__})
|
||||
|
||||
if "photobooth" in raw:
|
||||
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if hasattr(PhotoboothConfig, k)})
|
||||
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if k in PhotoboothConfig.__dataclass_fields__})
|
||||
|
||||
if "button" in raw:
|
||||
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if hasattr(ButtonConfig, k)})
|
||||
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if k in ButtonConfig.__dataclass_fields__})
|
||||
|
||||
if "leds" in raw:
|
||||
led_raw = raw["leds"]
|
||||
@@ -147,16 +153,18 @@ class ConfigService:
|
||||
dma=led_raw.get("dma", 10),
|
||||
strip_type=led_raw.get("strip_type", "WS2812"),
|
||||
effects=led_raw.get("effects", {}),
|
||||
countdown_duration=led_raw.get("countdown_duration", 3.0),
|
||||
pre_flash_advance=led_raw.get("pre_flash_advance", 0.5),
|
||||
)
|
||||
|
||||
if "print" in raw:
|
||||
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if hasattr(PrintConfig, k)})
|
||||
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if k in PrintConfig.__dataclass_fields__})
|
||||
|
||||
if "gallery" in raw:
|
||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
|
||||
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if k in GalleryConfig.__dataclass_fields__})
|
||||
|
||||
if "event" in raw:
|
||||
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
|
||||
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if k in EventConfig.__dataclass_fields__})
|
||||
|
||||
cfg.button_actions = raw.get("button_actions", {
|
||||
1: {"label": "Photo normale", "photobooth_index": 0},
|
||||
@@ -209,6 +217,39 @@ class ConfigService:
|
||||
self._config.event.slug = slug
|
||||
self._config.event.started_at = started_at
|
||||
|
||||
def save_user_print_config(self, enabled: bool, quota: int, max_copies: int):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
raw.setdefault("print", {}).update({
|
||||
"user_print_enabled": enabled,
|
||||
"user_print_quota": quota,
|
||||
"user_print_max_copies": max_copies,
|
||||
})
|
||||
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.print.user_print_enabled = enabled
|
||||
self._config.print.user_print_quota = quota
|
||||
self._config.print.user_print_max_copies = max_copies
|
||||
|
||||
def save_printer_excluded(self, printer_name: str, excluded: bool):
|
||||
"""Marque (ou démarque) une imprimante comme exclue du load-balancing."""
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
printers = raw.get("print", {}).get("printers", [])
|
||||
for p in printers:
|
||||
if isinstance(p, dict) and p.get("name") == printer_name:
|
||||
p["excluded"] = excluded
|
||||
break
|
||||
raw.setdefault("print", {})["printers"] = printers
|
||||
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 p in self._config.print.printers:
|
||||
if isinstance(p, dict) and p.get("name") == printer_name:
|
||||
p["excluded"] = excluded
|
||||
break
|
||||
|
||||
def save_print_mode(self, mode: str):
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
@@ -80,6 +80,14 @@ class EventService:
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def archive_all_open(self):
|
||||
"""Marque TOUS les événements ouverts comme terminés."""
|
||||
await self._db.execute(
|
||||
"UPDATE events SET ended_at = ? WHERE ended_at IS NULL",
|
||||
(datetime.now().timestamp(),),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def increment(self, slug: str, counter: str):
|
||||
"""Incrémente un compteur de l'événement identifié par son slug.
|
||||
|
||||
@@ -97,6 +105,17 @@ class EventService:
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def get_event_by_slug(self, slug: str) -> dict | None:
|
||||
"""Retourne un événement complet par son slug."""
|
||||
async with self._db.execute(
|
||||
"""SELECT id, name, slug, started_at, ended_at,
|
||||
photos_taken, print_requests, prints_done, downloads
|
||||
FROM events WHERE slug = ?""",
|
||||
(slug,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_stats(self, slug: str) -> dict:
|
||||
"""Retourne les stats pour un slug donné."""
|
||||
async with self._db.execute(
|
||||
|
||||
@@ -190,25 +190,60 @@ class LEDService:
|
||||
step += 1
|
||||
|
||||
def _effect_countdown(self, duration: float = 5.0):
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
n = self._cfg.count
|
||||
blank = WS_Color(0, 0, 0)
|
||||
color = WS_Color(c[0], c[1], c[2])
|
||||
|
||||
self._fill(blank)
|
||||
# Mode sombre : rampe progressive avec couleur flash pour stabiliser AWB caméra
|
||||
lux = self._ambient_lux
|
||||
dark_mode = self._cfg.auto_brightness and lux >= 0 and lux < 50
|
||||
|
||||
if dark_mode:
|
||||
flash_cfg = self._cfg.get_effect("capture")
|
||||
fc = flash_cfg.color
|
||||
color = WS_Color(fc[0], fc[1], fc[2])
|
||||
# Brightness cible du flash
|
||||
target_br = 255 if lux < 50 else (int(255 - (lux - 50) / 130 * 135) if lux < 180 else 80)
|
||||
# Rampe : atteindre target_br quand il reste pre_flash_advance secondes
|
||||
ramp_duration = max(0.1, duration - self._cfg.pre_flash_advance)
|
||||
orig_br = self._cfg.brightness
|
||||
logger.info("Countdown mode sombre: rampe 0→%d sur %.1fs puis stable", target_br, ramp_duration)
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(0)
|
||||
self._fill(color)
|
||||
else:
|
||||
cfg = self._cfg.get_effect("countdown")
|
||||
c = cfg.color
|
||||
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 dark_mode:
|
||||
# Rampe de 0 → target_br jusqu'à ramp_duration, puis stable à target_br
|
||||
if elapsed < ramp_duration:
|
||||
br = int(target_br * elapsed / ramp_duration)
|
||||
else:
|
||||
br = target_br
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(br)
|
||||
self._strip.show()
|
||||
else:
|
||||
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)
|
||||
|
||||
# En mode sombre, restaurer brightness normale (capture le fera aussi, mais par sécurité)
|
||||
if dark_mode and HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(orig_br)
|
||||
|
||||
def _effect_capture(self):
|
||||
"""Flash photo.
|
||||
|
||||
@@ -241,15 +276,22 @@ class LEDService:
|
||||
if HAS_WS281X and self._strip:
|
||||
self._strip.setBrightness(flash_brightness)
|
||||
|
||||
# Le flash démarre pre_flash_advance secondes AVANT la photo.
|
||||
# La durée ON doit couvrir ce délai + la durée d'exposition.
|
||||
# Sinon les LEDs s'éteignent avant que la photo soit prise.
|
||||
effective_on_time = self._cfg.pre_flash_advance + cfg.flash_duration
|
||||
|
||||
try:
|
||||
for _ in range(cfg.flashes):
|
||||
for i in range(cfg.flashes):
|
||||
if self._cmd_event.is_set():
|
||||
break
|
||||
self._fill(flash_color)
|
||||
time.sleep(cfg.flash_duration)
|
||||
# Premier flash : durée étendue pour couvrir le délai + expo
|
||||
# Flashs suivants : durée normale (décoratif post-capture)
|
||||
on_time = effective_on_time if i == 0 else cfg.flash_duration
|
||||
time.sleep(on_time)
|
||||
self._fill(off)
|
||||
# Pause inter-flash plus courte que le flash lui-meme
|
||||
if _ < cfg.flashes - 1:
|
||||
if i < cfg.flashes - 1:
|
||||
time.sleep(cfg.flash_duration * 0.4)
|
||||
finally:
|
||||
# Toujours restaurer la luminosite normale meme en cas d'erreur
|
||||
|
||||
@@ -53,6 +53,29 @@ class PhotoboothService:
|
||||
items = await self.get_media_collection(limit=1)
|
||||
return items[0] if items else None
|
||||
|
||||
async def get_media_item(self, media_id: str) -> dict | None:
|
||||
"""Retourne les détails complets d'un item (id, created_at, processed, ...)."""
|
||||
try:
|
||||
r = await self._client.get(f"/api/mediacollection/{media_id}")
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error("Erreur récupération item %s: %s", media_id, e)
|
||||
return None
|
||||
|
||||
def media_file_path(self, item: dict) -> Path | None:
|
||||
"""Retourne le chemin absolu du fichier traité depuis le champ 'processed' d'un item.
|
||||
|
||||
photobooth-app retourne 'processed': 'media/processed_full/YYYYMMDD-HHMMSS-xxx.jpg'
|
||||
On préfixe avec data_dir pour obtenir le chemin complet.
|
||||
"""
|
||||
processed = item.get("processed")
|
||||
if not processed:
|
||||
return None
|
||||
return Path(self._cfg.data_dir) / processed
|
||||
|
||||
async def delete_media(self, media_id: str) -> bool:
|
||||
"""Supprime une photo via l'API photobooth-app."""
|
||||
try:
|
||||
@@ -168,26 +191,41 @@ class PhotoboothService:
|
||||
"""Tente de recuperer une frame JPEG du liveview de photobooth-app.
|
||||
|
||||
Essaie dans l'ordre :
|
||||
1. /api/stream/snapshot — endpoint snapshot direct (si disponible)
|
||||
2. /stream.mjpg — flux MJPEG classique, extrait la premiere frame
|
||||
3. /api/stream — flux MJPEG alternatif
|
||||
1. Endpoints snapshot direct (retour JPEG immédiat)
|
||||
2. Flux MJPEG — extrait la premiere frame
|
||||
|
||||
Retourne des bytes JPEG ou None si rien n'est disponible.
|
||||
"""
|
||||
# 1. Snapshot direct
|
||||
for path in ("/api/stream/snapshot",):
|
||||
# 1. Endpoints snapshot direct (differentes versions de photobooth-app)
|
||||
snapshot_paths = (
|
||||
"/api/stream/snapshot",
|
||||
"/api/video/preview/snapshot",
|
||||
"/api/livestream/snapshot",
|
||||
"/api/cam/stream/snapshot",
|
||||
)
|
||||
for path in snapshot_paths:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
r = await client.get(f"{self._base}{path}")
|
||||
if r.status_code == 200:
|
||||
ct = r.headers.get("content-type", "")
|
||||
if "jpeg" in ct or "image" in ct:
|
||||
logger.debug("Snapshot OK via %s", path)
|
||||
return r.content
|
||||
except Exception as e:
|
||||
logger.debug("Snapshot %s: %s", path, e)
|
||||
|
||||
# 2. Premiere frame d'un flux MJPEG
|
||||
for path in ("/stream.mjpg", "/api/stream"):
|
||||
# 2. Premiere frame d'un flux MJPEG (differentes versions)
|
||||
mjpeg_paths = (
|
||||
"/api/aquisition/stream.mjpg", # photobooth-app v4+ (typo intentionnelle dans leur code)
|
||||
"/stream.mjpg",
|
||||
"/api/stream",
|
||||
"/api/video/stream",
|
||||
"/api/video/preview",
|
||||
"/api/livestream",
|
||||
"/livestream",
|
||||
)
|
||||
for path in mjpeg_paths:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
async with client.stream("GET", f"{self._base}{path}") as resp:
|
||||
@@ -200,12 +238,14 @@ class PhotoboothService:
|
||||
if start >= 0:
|
||||
end = buf.find(b"\xff\xd9", start)
|
||||
if end >= 0:
|
||||
logger.debug("MJPEG frame OK via %s", path)
|
||||
return buf[start:end + 2]
|
||||
if len(buf) > 500_000:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("MJPEG %s: %s", path, e)
|
||||
|
||||
logger.debug("Aucun endpoint liveview disponible sur %s", self._base)
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
@@ -21,6 +22,7 @@ PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"]
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS print_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
photo_id TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
thumb_url TEXT,
|
||||
copies INTEGER DEFAULT 1,
|
||||
@@ -39,6 +41,11 @@ class PrinterService:
|
||||
self._db_path: Path | None = None
|
||||
self._db: aiosqlite.Connection | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._led = None # injecté après démarrage via set_led_service()
|
||||
|
||||
def set_led_service(self, led_svc) -> None:
|
||||
"""Injecte le service LED pour que execute_print puisse mettre à jour les LEDs."""
|
||||
self._led = led_svc
|
||||
|
||||
async def init_db(self, db_path: Path):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -46,6 +53,12 @@ class PrinterService:
|
||||
self._db = await aiosqlite.connect(str(db_path))
|
||||
self._db.row_factory = aiosqlite.Row
|
||||
await self._db.execute(CREATE_SQL)
|
||||
# Migration : ajoute photo_id si la colonne n'existe pas encore
|
||||
try:
|
||||
await self._db.execute("ALTER TABLE print_queue ADD COLUMN photo_id TEXT")
|
||||
logger.info("Migration : colonne photo_id ajoutée à print_queue")
|
||||
except Exception:
|
||||
pass # Colonne déjà présente
|
||||
await self._db.commit()
|
||||
logger.info("Base print_queue initialisée: %s", db_path)
|
||||
|
||||
@@ -55,20 +68,23 @@ class PrinterService:
|
||||
|
||||
# ── File d'attente ────────────────────────────────────────────────────────
|
||||
|
||||
async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> dict:
|
||||
async def add_request(
|
||||
self, filename: str, thumb_url: str = "", copies: int = 1, photo_id: str = ""
|
||||
) -> dict:
|
||||
"""Ajoute une demande d'impression dans la file. Retourne l'entrée créée."""
|
||||
entry_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
|
||||
await self._db.execute(
|
||||
"INSERT INTO print_queue (id, filename, thumb_url, copies, status, requested_at) "
|
||||
"VALUES (?, ?, ?, ?, 'pending', ?)",
|
||||
(entry_id, filename, thumb_url, copies, now),
|
||||
"INSERT INTO print_queue (id, photo_id, filename, thumb_url, copies, status, requested_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
(entry_id, photo_id, filename, thumb_url, copies, now),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
"photo_id": photo_id,
|
||||
"filename": filename,
|
||||
"thumb_url": thumb_url,
|
||||
"copies": copies,
|
||||
@@ -101,29 +117,53 @@ class PrinterService:
|
||||
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.
|
||||
"""Retourne {photo_id: [entries actives]}. Conservé pour compatibilité."""
|
||||
stats = await self.get_print_stats_by_photo_id()
|
||||
return {pid: info["requests"] for pid, info in stats.items() if info["requests"]}
|
||||
|
||||
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.
|
||||
async def get_print_stats_by_photo_id(self) -> dict[str, dict]:
|
||||
"""Stats complètes d'impression par photo_id.
|
||||
|
||||
Retourne {photo_id: {requests, pending, printing, done, copies_done}}.
|
||||
'requests' contient uniquement les entrées pending/printing (pour la lightbox).
|
||||
'done' et 'copies_done' comptent les impressions réussies.
|
||||
"""
|
||||
rows = await self.get_queue("pending")
|
||||
# Aussi inclure celles "printing" (en cours d'impression)
|
||||
rows += await self.get_queue("printing")
|
||||
async with self._db.execute(
|
||||
"SELECT id, photo_id, filename, status, copies, requested_at, thumb_url "
|
||||
"FROM print_queue WHERE status IN ('pending','printing','done') "
|
||||
"ORDER BY requested_at"
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
result: dict[str, list] = {}
|
||||
result: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
from pathlib import Path
|
||||
stem = Path(r["filename"]).stem
|
||||
result.setdefault(stem, []).append(r)
|
||||
key = r["photo_id"] or Path(r["filename"]).stem
|
||||
if key not in result:
|
||||
result[key] = {
|
||||
"requests": [],
|
||||
"pending": 0,
|
||||
"printing": 0,
|
||||
"done": 0,
|
||||
"copies_done": 0,
|
||||
}
|
||||
if r["status"] in ("pending", "printing"):
|
||||
result[key]["requests"].append(dict(r))
|
||||
if r["status"] == "pending":
|
||||
result[key]["pending"] += 1
|
||||
elif r["status"] == "printing":
|
||||
result[key]["printing"] += 1
|
||||
elif r["status"] == "done":
|
||||
result[key]["done"] += 1
|
||||
result[key]["copies_done"] += r["copies"] or 1
|
||||
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é."""
|
||||
async def cancel_by_photo_id(self, photo_id: str) -> int:
|
||||
"""Annule toutes les demandes pending pour un photo_id (UUID) donné."""
|
||||
pending = await self.get_queue("pending")
|
||||
from pathlib import Path
|
||||
cancelled = 0
|
||||
for r in pending:
|
||||
if Path(r["filename"]).stem == photo_stem:
|
||||
key = r.get("photo_id") or Path(r["filename"]).stem
|
||||
if key == photo_id:
|
||||
ok = await self.cancel(r["id"])
|
||||
if ok:
|
||||
cancelled += 1
|
||||
@@ -189,6 +229,12 @@ class PrinterService:
|
||||
await self._db.commit()
|
||||
|
||||
logger.info("Impression %s: %s", entry_id, "OK" if result["success"] else result.get("error"))
|
||||
|
||||
# Mise à jour LED après impression (utile surtout en mode direct / background task)
|
||||
if self._led:
|
||||
effect = "finished" if result["success"] else "error"
|
||||
self._led.play(effect)
|
||||
|
||||
return result
|
||||
|
||||
def _run_print_script(self, script: str, filename: str, copies: int) -> dict:
|
||||
@@ -198,23 +244,113 @@ class PrinterService:
|
||||
if not Path(filename).exists():
|
||||
return {"success": False, "error": f"Fichier introuvable: {filename}"}
|
||||
|
||||
cmd = ["/bin/bash", script, filename, "image", "default", str(copies)]
|
||||
# Imprimantes exclues du load-balancing (flag excluded dans config)
|
||||
excluded_list = ",".join(
|
||||
self._printer_name(p)
|
||||
for p in self._cfg.printers
|
||||
if isinstance(p, dict) and p.get("excluded", False)
|
||||
)
|
||||
cmd = ["/bin/bash", script, filename, "image", "default", str(copies), excluded_list]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
stdout = proc.stdout.strip()
|
||||
if proc.returncode == 0 and "PRINTED:" in stdout:
|
||||
parts = stdout.split(":")
|
||||
printer = parts[1] if len(parts) > 1 else ""
|
||||
return {"success": True, "printer": printer, "output": stdout}
|
||||
else:
|
||||
return {"success": False, "error": proc.stderr.strip() or stdout, "printer": ""}
|
||||
# Cherche PRINTED: ou PRINT_ERROR: dans la dernière ligne significative
|
||||
for line in reversed(stdout.splitlines()):
|
||||
line = line.strip()
|
||||
if line.startswith("PRINTED:"):
|
||||
parts = line.split(":")
|
||||
printer = parts[1] if len(parts) > 1 else ""
|
||||
return {"success": True, "printer": printer, "output": stdout}
|
||||
if line.startswith("PRINT_ERROR:"):
|
||||
parts = line.split(":", 2)
|
||||
printer = parts[1] if len(parts) > 1 else ""
|
||||
reason = parts[2] if len(parts) > 2 else ""
|
||||
err_msg = f"Erreur imprimante {printer}: {reason}" if reason else f"Erreur imprimante {printer}"
|
||||
return {"success": False, "error": err_msg, "printer": printer}
|
||||
# Aucun marqueur reconnu
|
||||
return {"success": False, "error": proc.stderr.strip() or stdout or "Script sans sortie reconnue", "printer": ""}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": "Timeout impression (60s)"}
|
||||
return {"success": False, "error": "Timeout impression (120s)"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ── IPP direct ───────────────────────────────────────────────────────────
|
||||
|
||||
def _get_printer_ip(self, printer_name: str) -> str:
|
||||
"""Extrait l'IP de l'imprimante depuis l'URI CUPS (lpstat -v)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["lpstat", "-v", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", r.stdout)
|
||||
return m.group(1) if m else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _query_ipp_direct(self, printer_ip: str) -> dict:
|
||||
"""Interroge directement le serveur IPP natif de l'imprimante (sans auth).
|
||||
|
||||
Retourne un dict avec keys: state, state_reasons, accepting, markers.
|
||||
"""
|
||||
ipp_test = "/usr/share/cups/ipptool/get-printer-attributes.test"
|
||||
empty: dict = {"state": "", "state_reasons": [], "accepting": None, "markers": []}
|
||||
if not printer_ip:
|
||||
return empty
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ipptool", "-tv", f"ipp://{printer_ip}:631/ipp/print", ipp_test],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
out = r.stdout
|
||||
except Exception as e:
|
||||
logger.debug("ipptool direct échec %s: %s", printer_ip, e)
|
||||
return empty
|
||||
|
||||
result: dict = {"state": "", "state_reasons": [], "accepting": None, "markers": []}
|
||||
|
||||
# printer-state
|
||||
m = re.search(r"printer-state \(enum\)\s*=\s*(\S+)", out)
|
||||
if m:
|
||||
result["state"] = m.group(1).lower() # idle / processing / stopped
|
||||
|
||||
# printer-state-reasons
|
||||
m = re.search(r"printer-state-reasons \([^)]+\)\s*=\s*(.+)", out)
|
||||
if m:
|
||||
reasons = [r.strip() for r in m.group(1).split(",")]
|
||||
result["state_reasons"] = [r for r in reasons if r and r != "none"]
|
||||
|
||||
# printer-is-accepting-jobs
|
||||
m = re.search(r"printer-is-accepting-jobs \(boolean\)\s*=\s*(\S+)", out)
|
||||
if m:
|
||||
result["accepting"] = m.group(1).lower() == "true"
|
||||
|
||||
# marker-levels (encre/ruban)
|
||||
names_m = re.search(r"marker-names \([^)]+\)\s*=\s*(.+)", out)
|
||||
levels_m = re.search(r"marker-levels \([^)]+\)\s*=\s*(.+)", out)
|
||||
colors_m = re.search(r"marker-colors \([^)]+\)\s*=\s*(.+)", out)
|
||||
types_m = re.search(r"marker-types \([^)]+\)\s*=\s*(.+)", out)
|
||||
if names_m and levels_m:
|
||||
names = [n.strip().strip('"') for n in names_m.group(1).split(",")]
|
||||
levels = [l.strip() for l in levels_m.group(1).split(",")]
|
||||
colors = [c.strip().strip('"') for c in colors_m.group(1).split(",")] if colors_m else []
|
||||
types = [t.strip().strip('"') for t in types_m.group(1).split(",")] if types_m else []
|
||||
for i, name in enumerate(names):
|
||||
try:
|
||||
lvl = int(levels[i]) if i < len(levels) else -1
|
||||
except ValueError:
|
||||
lvl = -1
|
||||
result["markers"].append({
|
||||
"name": name,
|
||||
"level": lvl,
|
||||
"color": colors[i] if i < len(colors) else "",
|
||||
"type": types[i] if i < len(types) else "",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _printer_name(self, p) -> str:
|
||||
@@ -231,27 +367,52 @@ class PrinterService:
|
||||
statuses = []
|
||||
for p in self._cfg.printers:
|
||||
name = self._printer_name(p)
|
||||
excluded = isinstance(p, dict) and bool(p.get("excluded", False))
|
||||
status = await asyncio.to_thread(self._get_printer_status, name)
|
||||
stats = await self._get_printer_db_stats(name)
|
||||
statuses.append({
|
||||
"name": name,
|
||||
"label": self._printer_label(p),
|
||||
"excluded": excluded,
|
||||
**status,
|
||||
**stats,
|
||||
})
|
||||
return statuses
|
||||
|
||||
def _get_printer_status(self, printer_name: str) -> dict:
|
||||
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
|
||||
async def _get_printer_db_stats(self, printer_name: str) -> dict:
|
||||
"""Retourne les stats de la file SQLite pour une imprimante donnée."""
|
||||
try:
|
||||
# État de l'imprimante
|
||||
cursor = await self._db.execute(
|
||||
"SELECT status, COUNT(*) as cnt FROM print_queue WHERE printer = ? GROUP BY status",
|
||||
(printer_name,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
counts = {r["status"]: r["cnt"] for r in rows}
|
||||
# Aussi compter les jobs sans printer assigné (mode direct)
|
||||
cursor2 = await self._db.execute(
|
||||
"SELECT COUNT(*) as total FROM print_queue WHERE status='done'"
|
||||
)
|
||||
r2 = await cursor2.fetchone()
|
||||
return {
|
||||
"stats_done": counts.get("done", 0),
|
||||
"stats_error": counts.get("error", 0),
|
||||
"stats_cancelled": counts.get("cancelled", 0),
|
||||
}
|
||||
except Exception:
|
||||
return {"stats_done": 0, "stats_error": 0, "stats_cancelled": 0}
|
||||
|
||||
def _get_printer_status(self, printer_name: str) -> dict:
|
||||
"""Statut complet : CUPS pour les jobs, IPP direct pour état/erreurs/encre."""
|
||||
try:
|
||||
# ── Etat de base via lpstat ──
|
||||
r_state = subprocess.run(
|
||||
["lpstat", "-p", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
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}
|
||||
|
||||
out = r_state.stdout.lower()
|
||||
if "idle" in out:
|
||||
state = "idle"
|
||||
elif "printing" in out or "processing" in out:
|
||||
@@ -261,25 +422,149 @@ class PrinterService:
|
||||
else:
|
||||
state = "unknown"
|
||||
|
||||
# Est-ce que l'imprimante accepte les nouveaux jobs ?
|
||||
# Est-ce que l'imprimante accepte les nouveaux jobs (CUPS) ?
|
||||
r_accept = subprocess.run(
|
||||
["lpstat", "-a", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
accepting = "accepting" in r_accept.stdout.lower()
|
||||
|
||||
# Liste des jobs CUPS en cours
|
||||
# Jobs CUPS en cours
|
||||
jobs = self._get_cups_jobs(printer_name)
|
||||
|
||||
# URI du périphérique
|
||||
uri = ""
|
||||
printer_ip = ""
|
||||
try:
|
||||
r_uri = subprocess.run(
|
||||
["lpstat", "-v", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in r_uri.stdout.splitlines():
|
||||
if "device for" in line.lower():
|
||||
parts = line.split(":", 2)
|
||||
uri = (parts[1].strip() + ":" + parts[2].strip()) if len(parts) > 2 else ""
|
||||
break
|
||||
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", r_uri.stdout)
|
||||
printer_ip = m.group(1) if m else ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Modèle / location via lpstat -l -p
|
||||
model = ""
|
||||
location = ""
|
||||
reasons: list[str] = []
|
||||
try:
|
||||
r_info = subprocess.run(
|
||||
["lpstat", "-l", "-p", printer_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in r_info.stdout.splitlines():
|
||||
l = line.strip()
|
||||
if l.startswith("Description:"):
|
||||
model = l.split(":", 1)[1].strip()
|
||||
elif l.startswith("Location:"):
|
||||
location = l.split(":", 1)[1].strip()
|
||||
elif l.lower().startswith("reason:"):
|
||||
raw = l.split(":", 1)[1].strip()
|
||||
if raw and raw.lower() not in ("none", ""):
|
||||
reasons.append(self._REASON_LABELS.get(raw, raw))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Sondage IPP direct sur l'imprimante (source de vérité) ──
|
||||
markers: list[dict] = []
|
||||
if printer_ip:
|
||||
ipp = self._query_ipp_direct(printer_ip)
|
||||
if ipp["state"]:
|
||||
# Mappe les états IPP → notre convention
|
||||
ipp_state_map = {
|
||||
"idle": "idle",
|
||||
"processing": "printing",
|
||||
"stopped": "disabled",
|
||||
}
|
||||
state = ipp_state_map.get(ipp["state"], ipp["state"])
|
||||
if ipp["accepting"] is not None:
|
||||
accepting = ipp["accepting"]
|
||||
if ipp["state_reasons"]:
|
||||
reasons = [
|
||||
self._REASON_LABELS.get(r, r)
|
||||
for r in ipp["state_reasons"]
|
||||
]
|
||||
if ipp["markers"]:
|
||||
markers = ipp["markers"]
|
||||
else:
|
||||
markers = self._get_marker_levels(printer_name)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"accepting": accepting,
|
||||
"jobs": jobs,
|
||||
"state": state,
|
||||
"accepting": accepting,
|
||||
"jobs": jobs,
|
||||
"jobs_count": len(jobs),
|
||||
"uri": uri,
|
||||
"model": model,
|
||||
"location": location,
|
||||
"reasons": reasons,
|
||||
"markers": markers,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
|
||||
|
||||
# Traduction des Reason CUPS en français
|
||||
_REASON_LABELS: dict[str, str] = {
|
||||
"input-tray-missing": "⚠️ Bac papier absent",
|
||||
"media-empty": "❌ Plus de papier",
|
||||
"media-low": "⚠️ Papier presque épuisé",
|
||||
"media-needed": "⚠️ Papier requis",
|
||||
"marker-supply-empty": "❌ Cartouche/ruban vide",
|
||||
"marker-supply-low": "⚠️ Cartouche/ruban faible",
|
||||
"marker-supply-low-warning": "⚠️ Ruban faible",
|
||||
"cover-open": "❌ Capot ouvert",
|
||||
"door-open": "❌ Porte ouverte",
|
||||
"offline-report": "❌ Imprimante hors ligne",
|
||||
"connecting-to-device": "🔄 Connexion en cours…",
|
||||
"toner-empty": "❌ Toner vide",
|
||||
"toner-low": "⚠️ Toner faible",
|
||||
"output-tray-missing": "⚠️ Bac de sortie absent",
|
||||
"output-area-full": "⚠️ Bac de sortie plein",
|
||||
"paused": "⏸ Imprimante en pause",
|
||||
}
|
||||
|
||||
def _get_marker_levels(self, printer_name: str) -> list[dict]:
|
||||
"""Récupère les niveaux d'encre/ruban via lpstat ou ipptool."""
|
||||
markers = []
|
||||
try:
|
||||
# Essai via ipptool si disponible
|
||||
r = subprocess.run(
|
||||
["ipptool", "-tv",
|
||||
f"ipp://localhost:631/printers/{printer_name}",
|
||||
"/usr/share/cups/ipptool/get-printer-attributes.test"],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
lines = r.stdout.splitlines()
|
||||
names, levels, colors, types_ = [], [], [], []
|
||||
for line in lines:
|
||||
l = line.strip()
|
||||
if "marker-names" in l:
|
||||
names = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
|
||||
elif "marker-levels" in l:
|
||||
levels = [x.strip() for x in l.split("=", 1)[-1].split(",") if x.strip()]
|
||||
elif "marker-colors" in l:
|
||||
colors = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
|
||||
elif "marker-types" in l:
|
||||
types_ = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
|
||||
for i, name in enumerate(names):
|
||||
level = int(levels[i]) if i < len(levels) else -1
|
||||
markers.append({
|
||||
"name": name,
|
||||
"level": level,
|
||||
"color": colors[i] if i < len(colors) else "",
|
||||
"type": types_[i] if i < len(types_) else "",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return markers
|
||||
|
||||
def _get_cups_jobs(self, printer_name: str) -> list[dict]:
|
||||
"""Retourne la liste des jobs CUPS en cours pour une imprimante."""
|
||||
try:
|
||||
@@ -342,8 +627,8 @@ class PrinterService:
|
||||
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)
|
||||
r1 = subprocess.run(["sudo", "cupsenable", printer_name], capture_output=True, timeout=10)
|
||||
r2 = subprocess.run(["sudo", "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)
|
||||
@@ -352,7 +637,7 @@ class PrinterService:
|
||||
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)
|
||||
result = subprocess.run(["sudo", "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)
|
||||
@@ -361,7 +646,7 @@ class PrinterService:
|
||||
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)
|
||||
result = subprocess.run(["sudo", "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)
|
||||
|
||||
@@ -98,6 +98,10 @@ class SystemService:
|
||||
services = [
|
||||
("photobooth-app", "Photobooth App"),
|
||||
("jh-photomaton", "JH Photomaton"),
|
||||
("photobooth-kiosk", "Kiosk (Chromium)"),
|
||||
("zoraxy", "Zoraxy (Reverse Proxy)"),
|
||||
("hostapd", "WiFi Hotspot (hostapd)"),
|
||||
("dnsmasq", "DNS/DHCP (dnsmasq)"),
|
||||
("cups", "CUPS (Impression)"),
|
||||
]
|
||||
result = []
|
||||
|
||||
+23
-21
@@ -15,30 +15,31 @@ button:
|
||||
print_enabled: false
|
||||
relay_pin: 12
|
||||
button_actions:
|
||||
1:
|
||||
'1':
|
||||
label: Photo normale
|
||||
photobooth_index: 0
|
||||
2:
|
||||
'2':
|
||||
label: Photo etoile
|
||||
photobooth_index: 1
|
||||
3:
|
||||
label: Photo cailloux
|
||||
'3':
|
||||
label: Photo nuage
|
||||
photobooth_index: 2
|
||||
4:
|
||||
label: Photo soiree
|
||||
'4':
|
||||
label: Photo feu
|
||||
photobooth_index: 3
|
||||
event:
|
||||
name: Evenement
|
||||
slug: evenement
|
||||
started_at: 0
|
||||
name: CLEM - 30 ans
|
||||
slug: clem_30_ans
|
||||
started_at: 1784292610.030078
|
||||
gallery:
|
||||
photos_per_page: 24
|
||||
public_enabled: true
|
||||
qr_base_url: https://photomaton-galerie.lessapinsduweb.com
|
||||
leds:
|
||||
auto_brightness: true
|
||||
brightness: 180
|
||||
brightness: 20
|
||||
count: 35
|
||||
countdown_duration: 5.0 # Durée du countdown photobooth-app (secondes)
|
||||
dma: 10
|
||||
effects:
|
||||
capture:
|
||||
@@ -46,18 +47,18 @@ leds:
|
||||
- 255
|
||||
- 200
|
||||
- 80
|
||||
flash_duration: 0.3
|
||||
flashes: 2
|
||||
flash_duration: 1.0
|
||||
flashes: 1
|
||||
mode: flash
|
||||
captured:
|
||||
color:
|
||||
- 150
|
||||
- 0
|
||||
- 200
|
||||
- 255
|
||||
- 0
|
||||
mode: solid
|
||||
countdown:
|
||||
color:
|
||||
- 0
|
||||
- 255
|
||||
- 200
|
||||
- 80
|
||||
mode: fill_progressive
|
||||
@@ -83,31 +84,32 @@ leds:
|
||||
mode: solid
|
||||
idle:
|
||||
color:
|
||||
- 0
|
||||
- 50
|
||||
- 30
|
||||
- 80
|
||||
- 10
|
||||
mode: solid
|
||||
speed: 0.025
|
||||
printing:
|
||||
color:
|
||||
- 150
|
||||
- 0
|
||||
- 120
|
||||
- 255
|
||||
- 200
|
||||
mode: spin
|
||||
speed: 0.05
|
||||
freq_hz: 800000
|
||||
pin: 18
|
||||
pre_flash_advance: 2.5 # Démarrer le flash X secondes avant la fin du countdown
|
||||
strip_type: WS2812
|
||||
photobooth:
|
||||
public_url: https://photomaton.lessapinsduweb.com
|
||||
config_file: /home/pi/photobooth-data/config/config.json
|
||||
data_dir: /home/pi/photobooth-data
|
||||
media_dir: /home/pi/photobooth-data/media/processed_full
|
||||
public_url: https://photomaton.lessapinsduweb.com
|
||||
show_delete_button: false
|
||||
userdata_dir: /home/pi/photobooth-data/userdata
|
||||
print:
|
||||
default_copies: 1
|
||||
mode: validation
|
||||
mode: gallery
|
||||
printers:
|
||||
- label: Selphy Blanche (WiFi)
|
||||
name: Selphy_Blanche_WiFi
|
||||
|
||||
@@ -265,11 +265,11 @@ select.form-control option { background: var(--surface2); }
|
||||
}
|
||||
|
||||
.led-ring.idle { border-color: #1e4080; box-shadow: 0 0 15px rgba(30,64,128,0.5); color: #4080d0; }
|
||||
.led-ring.countdown { border-color: var(--led-green); box-shadow: 0 0 20px rgba(34,197,94,0.6); color: var(--led-green); animation: pulse-green 1s infinite; }
|
||||
.led-ring.countdown { border-color: #ffc850; box-shadow: 0 0 20px rgba(255,200,80,0.7); color: #ffc850; animation: pulse-green 1s infinite; }
|
||||
.led-ring.capture { border-color: #fff; box-shadow: 0 0 30px rgba(255,255,255,0.8); color: #fff; }
|
||||
.led-ring.captured { border-color: var(--led-purple); box-shadow: 0 0 20px rgba(168,85,247,0.6); color: var(--led-purple); }
|
||||
.led-ring.captured { border-color: var(--led-green); box-shadow: 0 0 20px rgba(34,197,94,0.6); color: var(--led-green); }
|
||||
.led-ring.finished { border-color: var(--led-blue); box-shadow: 0 0 20px rgba(59,130,246,0.6); color: var(--led-blue); }
|
||||
.led-ring.printing { border-color: var(--led-blue); box-shadow: 0 0 20px rgba(59,130,246,0.4); color: var(--led-blue); animation: spin-ring 1s linear infinite; }
|
||||
.led-ring.printing { border-color: var(--led-purple); box-shadow: 0 0 20px rgba(168,85,247,0.5); color: var(--led-purple); animation: spin-ring 1s linear infinite; }
|
||||
.led-ring.error { border-color: var(--error); box-shadow: 0 0 20px rgba(239,68,68,0.6); color: var(--error); animation: blink 0.3s infinite; }
|
||||
.led-ring.disabled { border-color: #4a0000; box-shadow: 0 0 10px rgba(100,0,0,0.3); color: #800000; }
|
||||
.led-ring.off { border-color: var(--border); }
|
||||
|
||||
@@ -1,151 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Actions — JH Photomaton{% endblock %}
|
||||
|
||||
{% block nav_links %}
|
||||
<a href="/admin">Dashboard</a>
|
||||
<a href="/admin/gallery">Galerie</a>
|
||||
<a href="/admin/actions" class="active">Actions</a>
|
||||
<a href="/admin/print">Impression</a>
|
||||
<a href="/admin/settings">Réglages</a>
|
||||
<a href="/admin/logout">Déconnexion</a>
|
||||
{% block head %}
|
||||
<style>
|
||||
/* ── Cards actions ──────────────────────────────────────────────────── */
|
||||
.action-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: .6rem;
|
||||
transition: box-shadow .15s, opacity .15s;
|
||||
cursor: default;
|
||||
}
|
||||
.action-card.drag-over {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px var(--primary);
|
||||
}
|
||||
.action-card.dragging { opacity: .35; }
|
||||
|
||||
.ac-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
padding: .75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
.ac-idx {
|
||||
font-size: .7rem;
|
||||
background: var(--bg-muted);
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
padding: .1rem .4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ac-name {
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
.ac-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
flex: 1;
|
||||
}
|
||||
.ac-chip {
|
||||
font-size: .72rem;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 10px;
|
||||
padding: .1rem .5rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ac-chip.hl { background: var(--primary-muted, rgba(99,102,241,.15)); color: var(--primary); }
|
||||
.ac-btns { display: flex; gap: .25rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Edit form ──────────────────────────────────────────────────────── */
|
||||
.ac-edit {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
}
|
||||
.edit-section { margin-bottom: 1rem; }
|
||||
.edit-section-title {
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .07em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: .5rem;
|
||||
padding-bottom: .25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── Presets ────────────────────────────────────────────────────────── */
|
||||
.preset-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .6rem .9rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
.preset-info { flex: 1; }
|
||||
.preset-name { font-weight: 600; }
|
||||
.preset-meta { font-size: .75rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Reorder bar ────────────────────────────────────────────────────── */
|
||||
#reorder-bar {
|
||||
display: none;
|
||||
background: rgba(245,158,11,.1);
|
||||
border: 1px solid rgba(245,158,11,.4);
|
||||
border-radius: var(--radius);
|
||||
padding: .5rem .75rem;
|
||||
font-size: .85rem;
|
||||
margin-bottom: .75rem;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1 class="page-title">Gestion des actions & mapping bouton</h1>
|
||||
|
||||
<!-- Mapping clics → actions -->
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="page-title" style="margin:0">Gestion des actions</h1>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm" id="restart-btn" onclick="restartPhotobooth()" style="display:none">
|
||||
🔄 Redémarrer photobooth-app
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="newAction()">+ Nouvelle action</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ──────────────────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<div class="card-title">Mapping bouton → actions photobooth-app</div>
|
||||
<div class="card-title">Actions image photobooth-app <span class="text-muted text-sm" id="action-count"></span></div>
|
||||
|
||||
<!-- Barre "ordre modifié" -->
|
||||
<div id="reorder-bar">
|
||||
<span>↕ Ordre modifié</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="saveOrder()">💾 Sauvegarder l'ordre</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadAll()">✕ Annuler</button>
|
||||
</div>
|
||||
|
||||
<div id="actions-list">
|
||||
<div class="empty-state"><div class="icon">⏳</div>Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Mapping bouton ───────────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<div class="card-title">Mapping bouton → actions</div>
|
||||
<div class="card">
|
||||
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action dans photobooth-app. L'index correspond à la position dans la liste des actions (0 = première action).</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Clics</th>
|
||||
<th>Label affiché</th>
|
||||
<th>Index action (photobooth)</th>
|
||||
<th>Action correspondante</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for n in range(1, 5) %}
|
||||
{% set mapping = button_mapping.get(n) or button_mapping.get(n|string) or {} %}
|
||||
<tr>
|
||||
<td class="font-bold">
|
||||
{% if n == 1 %}1 clic{% else %}{{ n }} clics{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" id="label-{{ n }}" class="form-control" style="max-width:200px"
|
||||
value="{{ mapping.get('label', '') }}" placeholder="Label…">
|
||||
</td>
|
||||
<td>
|
||||
<select id="index-{{ n }}" class="form-control" style="max-width:80px">
|
||||
{% for i in range(pb_actions|length) %}
|
||||
<option value="{{ i }}" {% if mapping.get('photobooth_index') == i %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-sm text-muted" id="action-name-{{ n }}">
|
||||
{% set idx = mapping.get('photobooth_index', 0) %}
|
||||
{% if pb_actions and idx < pb_actions|length %}
|
||||
{{ pb_actions[idx].name }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick="testAction({{ n }})">▶ Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action.</p>
|
||||
<div id="mapping-table">
|
||||
<div class="empty-state"><div class="icon">⏳</div></div>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions photobooth-app -->
|
||||
<!-- ── Presets ──────────────────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<div class="card-title">Actions image photobooth-app ({{ pb_actions|length }} au total)</div>
|
||||
<div id="actions-list">
|
||||
{% for action in pb_actions %}
|
||||
<div class="card mb-1" id="action-{{ loop.index0 }}" style="margin-bottom:0.75rem">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span class="badge badge-muted text-xs">Action {{ loop.index0 }}</span>
|
||||
<span class="font-bold" style="margin-left:0.5rem">{{ action.name }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">✏ Modifier</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="triggerAction({{ loop.index0 }})">▶ Déclencher</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infos résumées -->
|
||||
<div class="flex gap-2 text-sm text-muted" id="summary-{{ loop.index0 }}">
|
||||
<span>⏱ {{ action.jobcontrol.get('countdown_capture', '?') }}s</span>
|
||||
{% if action.processing.get('img_frame_file') %}
|
||||
<span>🖼 {{ action.processing.img_frame_file.split('/')[-1] }}</span>
|
||||
{% endif %}
|
||||
{% if action.processing.get('remove_background') %}
|
||||
<span class="badge badge-info">Remove BG</span>
|
||||
{% endif %}
|
||||
{% if action.processing.get('img_background_file') %}
|
||||
<span>🌄 {{ action.processing.img_background_file.split('/')[-1] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'édition (masqué par défaut) -->
|
||||
<div class="edit-form mt-2" id="edit-{{ loop.index0 }}" style="display:none;border-top:1px solid var(--border);padding-top:1rem">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nom de l'action</label>
|
||||
<input type="text" class="form-control" id="e-name-{{ loop.index0 }}" value="{{ action.name }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Countdown (secondes)</label>
|
||||
<input type="number" class="form-control" id="e-countdown-{{ loop.index0 }}"
|
||||
value="{{ action.jobcontrol.get('countdown_capture', 5) }}" min="1" max="30" step="0.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Cadre (img_frame_file)</label>
|
||||
<input type="text" class="form-control" id="e-frame-{{ loop.index0 }}"
|
||||
value="{{ action.processing.get('img_frame_file', '') or '' }}" placeholder="userdata/…/cadre.png">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Fond (img_background_file)</label>
|
||||
<input type="text" class="form-control" id="e-bg-{{ loop.index0 }}"
|
||||
value="{{ action.processing.get('img_background_file', '') or '' }}" placeholder="userdata/…/fond.jpg">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Filtre image</label>
|
||||
<select class="form-control" id="e-filter-{{ loop.index0 }}">
|
||||
<option value="original" {% if action.processing.get('image_filter','original') == 'original' %}selected{% endif %}>original</option>
|
||||
<option value="FilterPilgram2.earlybird" {% if 'earlybird' in (action.processing.get('image_filter','')) %}selected{% endif %}>Earlybird</option>
|
||||
<option value="FilterPilgram2.reyes" {% if 'reyes' in (action.processing.get('image_filter','')) %}selected{% endif %}>Reyes</option>
|
||||
<option value="FilterPilgram2.moon" {% if 'moon' in (action.processing.get('image_filter','')) %}selected{% endif %}>Moon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<input type="checkbox" id="e-rmbg-{{ loop.index0 }}" {% if action.processing.get('remove_background') %}checked{% endif %}>
|
||||
Remove Background (MODNet — ~400Mo RAM)
|
||||
</label>
|
||||
<label class="form-label flex items-center gap-1 mt-1">
|
||||
<input type="checkbox" id="e-bgena-{{ loop.index0 }}" {% if action.processing.get('img_background_enable') %}checked{% endif %}>
|
||||
Activer le fond
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveAction({{ loop.index0 }})">💾 Sauvegarder</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-title">Presets d'actions</div>
|
||||
<div class="card">
|
||||
<p class="text-sm text-muted mb-2">Sauvegarder la configuration actuelle pour la restaurer plus tard (ex : mariage, baptême, anniversaire…).</p>
|
||||
<div class="flex gap-1 mb-2" style="flex-wrap:wrap">
|
||||
<input type="text" class="form-control" id="preset-name" placeholder="Nom du preset…" style="max-width:220px">
|
||||
<input type="text" class="form-control" id="preset-desc" placeholder="Description (optionnel)" style="max-width:280px">
|
||||
<button class="btn btn-primary" onclick="savePreset()">💾 Sauvegarder le preset</button>
|
||||
</div>
|
||||
<div id="presets-list">
|
||||
<div class="empty-state text-sm">Aucun preset sauvegardé</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="icon">⚙️</div>Aucune action configurée dans photobooth-app</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,72 +178,464 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── Formulaires d'édition ─────────────────────────────────────────────────────
|
||||
function toggleEdit(idx) {
|
||||
const el = document.getElementById('edit-' + idx);
|
||||
const sum = document.getElementById('summary-' + idx);
|
||||
const open = el.style.display === 'none';
|
||||
el.style.display = open ? 'block' : 'none';
|
||||
sum.style.display = open ? 'none' : 'flex';
|
||||
// ── État global ───────────────────────────────────────────────────────────────
|
||||
let actions = [];
|
||||
let assets = { frames: [], backgrounds: [] };
|
||||
let mapping = {};
|
||||
let orderChanged = false;
|
||||
|
||||
const FILTERS = [
|
||||
['original', 'Original'],
|
||||
['FilterPilgram2.earlybird', 'Earlybird'],
|
||||
['FilterPilgram2.reyes', 'Reyes'],
|
||||
['FilterPilgram2.moon', 'Moon'],
|
||||
['FilterPilgram2.gingham', 'Gingham'],
|
||||
['FilterPilgram2.mayfair', 'Mayfair'],
|
||||
['FilterPilgram2.nashville', 'Nashville'],
|
||||
['FilterPilgram2.inkwell', 'Inkwell (N&B)'],
|
||||
];
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────────────────────
|
||||
async function loadAll() {
|
||||
orderChanged = false;
|
||||
document.getElementById('reorder-bar').style.display = 'none';
|
||||
try {
|
||||
const [actData, assetData] = await Promise.all([
|
||||
api('GET', '/api/actions/photobooth'),
|
||||
api('GET', '/api/actions/assets'),
|
||||
]);
|
||||
actions = actData.actions || [];
|
||||
mapping = actData.button_mapping || {};
|
||||
assets = assetData;
|
||||
renderActions();
|
||||
renderMapping();
|
||||
loadPresets();
|
||||
} catch(e) {
|
||||
document.getElementById('actions-list').innerHTML =
|
||||
`<div class="empty-state"><div class="icon">⚠️</div>Erreur chargement: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendu des actions ─────────────────────────────────────────────────────────
|
||||
function renderActions() {
|
||||
const el = document.getElementById('actions-list');
|
||||
document.getElementById('action-count').textContent = `(${actions.length})`;
|
||||
if (!actions.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">⚙️</div>Aucune action</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = actions.map((a, i) => cardHTML(a, i)).join('');
|
||||
attachDragListeners();
|
||||
}
|
||||
|
||||
function cardHTML(action, idx) {
|
||||
const name = esc(action.name || 'Sans nom');
|
||||
const proc = action.processing || {};
|
||||
const jc = action.jobcontrol || {};
|
||||
const chips = [
|
||||
`<span class="ac-chip">⏱ ${jc.countdown_capture ?? '?'}s</span>`,
|
||||
];
|
||||
if (proc.img_frame_enable && proc.img_frame_file)
|
||||
chips.push(`<span class="ac-chip">🖼 ${esc(proc.img_frame_file.split('/').pop())}</span>`);
|
||||
if (proc.img_background_enable && proc.img_background_file)
|
||||
chips.push(`<span class="ac-chip">🌄 ${esc(proc.img_background_file.split('/').pop())}</span>`);
|
||||
if (proc.fill_background_enable)
|
||||
chips.push(`<span class="ac-chip" style="background:${esc(proc.fill_background_color||'#eee')};color:#000">⬛ fond couleur</span>`);
|
||||
if (proc.remove_background)
|
||||
chips.push('<span class="ac-chip hl">Remove BG</span>');
|
||||
if (proc.image_filter && proc.image_filter !== 'original')
|
||||
chips.push(`<span class="ac-chip hl">🎨 ${esc(proc.image_filter.split('.').pop())}</span>`);
|
||||
|
||||
return `
|
||||
<div class="action-card" draggable="true" data-idx="${idx}" id="ac-${idx}">
|
||||
<div class="ac-header">
|
||||
<span class="drag-handle" title="Glisser pour réordonner">⠿⠿</span>
|
||||
<span class="ac-idx">#${idx}</span>
|
||||
<span class="ac-name">${name}</span>
|
||||
<div class="ac-chips">${chips.join('')}</div>
|
||||
<div class="ac-btns">
|
||||
<button class="btn btn-ghost btn-xs" onclick="toggleEdit(${idx})" title="Modifier">✏</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="cloneAction(${idx})" title="Dupliquer">⧉</button>
|
||||
<button class="btn btn-ghost btn-xs" onclick="triggerAction(${idx})" title="Déclencher">▶</button>
|
||||
<button class="btn btn-danger btn-xs" onclick="deleteAction(${idx})" title="Supprimer">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ac-edit" id="edit-${idx}" style="display:none"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Formulaire d'édition (rendu lazy au premier toggle) ───────────────────────
|
||||
function editFormHTML(action, idx) {
|
||||
const proc = action.processing || {};
|
||||
const jc = action.jobcontrol || {};
|
||||
|
||||
const frameSel = assetSelect(`ef-frame-sel-${idx}`, proc.img_frame_file || '', assets.frames, '— aucun cadre —', `ef-frame-${idx}`);
|
||||
const bgSel = assetSelect(`ef-bg-sel-${idx}`, proc.img_background_file || '', assets.backgrounds, '— aucun fond —', `ef-bg-${idx}`);
|
||||
const filterOpts = FILTERS.map(([v, l]) =>
|
||||
`<option value="${esc(v)}" ${(proc.image_filter||'original')===v?'selected':''}>${esc(l)}</option>`).join('');
|
||||
|
||||
return `
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-title">Général</div>
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nom de l'action</label>
|
||||
<input type="text" class="form-control" id="ef-name-${idx}" value="${esc(action.name||'')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Countdown (secondes)</label>
|
||||
<input type="number" class="form-control" id="ef-countdown-${idx}"
|
||||
value="${jc.countdown_capture??5}" min="1" max="60" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-title">Cadre</div>
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<input type="checkbox" id="ef-frame-ena-${idx}" ${proc.img_frame_enable?'checked':''}> Activer le cadre
|
||||
</label>
|
||||
${frameSel}
|
||||
<input type="text" class="form-control mt-1" id="ef-frame-${idx}"
|
||||
value="${esc(proc.img_frame_file||'')}" placeholder="userdata/…/cadre.png" style="font-size:.8rem">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-title">Fond</div>
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<input type="checkbox" id="ef-bg-ena-${idx}" ${proc.img_background_enable?'checked':''}> Activer le fond image
|
||||
</label>
|
||||
${bgSel}
|
||||
<input type="text" class="form-control mt-1" id="ef-bg-${idx}"
|
||||
value="${esc(proc.img_background_file||'')}" placeholder="userdata/…/fond.jpg" style="font-size:.8rem">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<input type="checkbox" id="ef-fill-ena-${idx}" ${proc.fill_background_enable?'checked':''}> Activer le fond couleur
|
||||
</label>
|
||||
<input type="color" class="form-control mt-1" id="ef-fill-color-${idx}"
|
||||
value="${esc(proc.fill_background_color||'#ededed')}" style="height:36px;max-width:80px">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-section">
|
||||
<div class="edit-section-title">Traitement image</div>
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<input type="checkbox" id="ef-rmbg-${idx}" ${proc.remove_background?'checked':''}>
|
||||
Remove Background (MODNet)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Filtre</label>
|
||||
<select class="form-control" id="ef-filter-${idx}">${filterOpts}</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" onclick="saveAction(${idx})">💾 Sauvegarder</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="toggleEdit(${idx})">Annuler</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function assetSelect(selId, current, options, placeholder, textId) {
|
||||
const opts = options.map(f => {
|
||||
const label = f.replace(/^userdata\//, '');
|
||||
return `<option value="${esc(f)}" ${f===current?'selected':''}>${esc(label)}</option>`;
|
||||
}).join('');
|
||||
return `<select class="form-control" id="${selId}"
|
||||
onchange="document.getElementById('${textId}').value=this.value">
|
||||
<option value="">${esc(placeholder)}</option>${opts}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
// ── Toggle édition ────────────────────────────────────────────────────────────
|
||||
function toggleEdit(idx) {
|
||||
const el = document.getElementById(`edit-${idx}`);
|
||||
if (el.style.display === 'none') {
|
||||
if (!el.dataset.rendered) {
|
||||
el.innerHTML = editFormHTML(actions[idx], idx);
|
||||
el.dataset.rendered = '1';
|
||||
}
|
||||
el.style.display = 'block';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarde action ─────────────────────────────────────────────────────────
|
||||
async function saveAction(idx) {
|
||||
const realIdx = actions[idx]._index ?? idx;
|
||||
const updates = {
|
||||
name: document.getElementById('e-name-' + idx).value,
|
||||
countdown_capture: parseFloat(document.getElementById('e-countdown-' + idx).value),
|
||||
img_frame_file: document.getElementById('e-frame-' + idx).value || null,
|
||||
img_background_file: document.getElementById('e-bg-' + idx).value || null,
|
||||
image_filter: document.getElementById('e-filter-' + idx).value,
|
||||
remove_background: document.getElementById('e-rmbg-' + idx).checked,
|
||||
img_background_enable: document.getElementById('e-bgena-' + idx).checked,
|
||||
name: document.getElementById(`ef-name-${idx}`).value,
|
||||
countdown_capture: parseFloat(document.getElementById(`ef-countdown-${idx}`).value),
|
||||
img_frame_enable: document.getElementById(`ef-frame-ena-${idx}`).checked,
|
||||
img_frame_file: document.getElementById(`ef-frame-${idx}`).value || null,
|
||||
img_background_enable: document.getElementById(`ef-bg-ena-${idx}`).checked,
|
||||
img_background_file: document.getElementById(`ef-bg-${idx}`).value || null,
|
||||
fill_background_enable: document.getElementById(`ef-fill-ena-${idx}`).checked,
|
||||
fill_background_color: document.getElementById(`ef-fill-color-${idx}`).value,
|
||||
remove_background: document.getElementById(`ef-rmbg-${idx}`).checked,
|
||||
image_filter: document.getElementById(`ef-filter-${idx}`).value,
|
||||
};
|
||||
try {
|
||||
await api('PUT', `/api/actions/photobooth/${idx}`, updates);
|
||||
showToast('✅ Action sauvegardée — redémarrage photobooth-app requis', 'success', 6000);
|
||||
toggleEdit(idx);
|
||||
await api('PUT', `/api/actions/photobooth/${realIdx}`, updates);
|
||||
showToast('✅ Action sauvegardée', 'success');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) {
|
||||
showToast('❌ Erreur sauvegarde', 'error');
|
||||
showToast(`❌ Erreur: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clone / Delete / Trigger ──────────────────────────────────────────────────
|
||||
async function cloneAction(idx) {
|
||||
const realIdx = actions[idx]._index ?? idx;
|
||||
try {
|
||||
await api('POST', `/api/actions/photobooth/${realIdx}/clone`);
|
||||
showToast('⧉ Action dupliquée', 'success');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAction(idx) {
|
||||
const name = actions[idx].name || `Action ${idx}`;
|
||||
if (!confirm(`Supprimer « ${name} » ?\nCette action sera définitivement retirée de photobooth-app.`)) return;
|
||||
const realIdx = actions[idx]._index ?? idx;
|
||||
try {
|
||||
await api('DELETE', `/api/actions/photobooth/${realIdx}`);
|
||||
showToast(`🗑 « ${name} » supprimée`, 'info');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function newAction() {
|
||||
const name = prompt('Nom de la nouvelle action :', 'Nouvelle action');
|
||||
if (name === null) return;
|
||||
try {
|
||||
await api('POST', '/api/actions/photobooth', { name: name.trim() || 'Nouvelle action' });
|
||||
showToast('✅ Action créée', 'success');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function triggerAction(idx) {
|
||||
if (!confirm(`Déclencher l'action ${idx} (prise de photo) ?`)) return;
|
||||
const realIdx = actions[idx]._index ?? idx;
|
||||
const name = actions[idx].name || `Action ${idx}`;
|
||||
if (!confirm(`Déclencher « ${name} » (prise de photo) ?`)) return;
|
||||
try {
|
||||
await api('POST', `/api/actions/trigger/${idx}`);
|
||||
showToast(`▶ Action ${idx} déclenchée`, 'info');
|
||||
} catch(e) { showToast('Erreur déclenchement', 'error'); }
|
||||
await api('POST', `/api/actions/trigger/${realIdx}`);
|
||||
showToast(`▶ « ${name} » déclenchée`, 'info');
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// ── Mapping bouton ─────────────────────────────────────────────────────────────
|
||||
async function saveMapping() {
|
||||
const mapping = {};
|
||||
// ── Drag & Drop ───────────────────────────────────────────────────────────────
|
||||
let dragSrcIdx = null;
|
||||
|
||||
function attachDragListeners() {
|
||||
document.querySelectorAll('.action-card').forEach(card => {
|
||||
const idx = parseInt(card.dataset.idx);
|
||||
card.addEventListener('dragstart', e => {
|
||||
dragSrcIdx = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setTimeout(() => card.classList.add('dragging'), 0);
|
||||
});
|
||||
card.addEventListener('dragend', () => {
|
||||
card.classList.remove('dragging');
|
||||
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
|
||||
});
|
||||
card.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
card.addEventListener('dragenter', e => {
|
||||
e.preventDefault();
|
||||
if (parseInt(card.dataset.idx) !== dragSrcIdx) {
|
||||
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
|
||||
card.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
card.addEventListener('dragleave', e => {
|
||||
if (!card.contains(e.relatedTarget)) card.classList.remove('drag-over');
|
||||
});
|
||||
card.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
const targetIdx = parseInt(card.dataset.idx);
|
||||
if (dragSrcIdx === null || dragSrcIdx === targetIdx) return;
|
||||
// Réordonne en mémoire
|
||||
const [moved] = actions.splice(dragSrcIdx, 1);
|
||||
actions.splice(targetIdx, 0, moved);
|
||||
dragSrcIdx = null;
|
||||
orderChanged = true;
|
||||
renderActions();
|
||||
document.getElementById('reorder-bar').style.display = 'flex';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveOrder() {
|
||||
// _index contient l'index original côté serveur
|
||||
const order = actions.map(a => a._index);
|
||||
try {
|
||||
await api('PUT', '/api/actions/photobooth/reorder', order);
|
||||
showToast('✅ Ordre sauvegardé', 'success');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// ── Mapping bouton ────────────────────────────────────────────────────────────
|
||||
function renderMapping() {
|
||||
const el = document.getElementById('mapping-table');
|
||||
const names = actions.map(a => a.name || '?');
|
||||
let html = '<table class="table"><thead><tr><th>Clics</th><th>Label</th><th>Action</th><th></th></tr></thead><tbody>';
|
||||
for (let n = 1; n <= 4; n++) {
|
||||
mapping[n] = {
|
||||
label: document.getElementById('label-' + n).value,
|
||||
photobooth_index: parseInt(document.getElementById('index-' + n).value),
|
||||
const m = mapping[n] || mapping[String(n)] || {};
|
||||
const selectedIdx = m.photobooth_index ?? 0;
|
||||
const opts = actions.map((a, i) =>
|
||||
`<option value="${i}" ${i===selectedIdx?'selected':''}>#${i} — ${esc(a.name||'?')}</option>`
|
||||
).join('');
|
||||
html += `<tr>
|
||||
<td class="font-bold">${n} clic${n>1?'s':''}</td>
|
||||
<td><input type="text" id="ml-${n}" class="form-control" style="max-width:180px" value="${esc(m.label||'')}" placeholder="Label…"></td>
|
||||
<td><select id="mi-${n}" class="form-control" style="max-width:280px">${opts}</select></td>
|
||||
<td><button class="btn btn-ghost btn-xs" onclick="testMapping(${n})">▶ Test</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function saveMapping() {
|
||||
const m = {};
|
||||
for (let n = 1; n <= 4; n++) {
|
||||
m[n] = {
|
||||
label: document.getElementById(`ml-${n}`).value,
|
||||
photobooth_index: parseInt(document.getElementById(`mi-${n}`).value),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await api('PUT', '/api/actions/mapping', mapping);
|
||||
await api('PUT', '/api/actions/mapping', m);
|
||||
showToast('✅ Mapping sauvegardé', 'success');
|
||||
} catch(e) { showToast('❌ Erreur sauvegarde mapping', 'error'); }
|
||||
mapping = m;
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function testAction(n) {
|
||||
const idx = parseInt(document.getElementById('index-' + n).value);
|
||||
if (!confirm(`Déclencher l'action ${idx} pour tester le ${n} clic ?`)) return;
|
||||
async function testMapping(n) {
|
||||
const idx = parseInt(document.getElementById(`mi-${n}`).value);
|
||||
const realIdx = actions[idx]?._index ?? idx;
|
||||
if (!confirm(`Déclencher action #${realIdx} (test ${n} clic) ?`)) return;
|
||||
try {
|
||||
await api('POST', `/api/actions/trigger/${idx}`);
|
||||
showToast(`▶ Test ${n} clic → action ${idx}`, 'info');
|
||||
} catch(e) { showToast('Erreur', 'error'); }
|
||||
await api('POST', `/api/actions/trigger/${realIdx}`);
|
||||
showToast(`▶ Test ${n} clic → action #${realIdx}`, 'info');
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// Mise à jour du nom de l'action quand on change l'index
|
||||
{% for n in range(1, 5) %}
|
||||
document.getElementById('index-{{ n }}').addEventListener('change', function() {
|
||||
const names = {{ pb_actions | map(attribute='name') | list | tojson }};
|
||||
document.getElementById('action-name-{{ n }}').textContent = names[this.value] || '—';
|
||||
});
|
||||
{% endfor %}
|
||||
// ── Presets ───────────────────────────────────────────────────────────────────
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const data = await api('GET', '/api/actions/presets');
|
||||
renderPresets(data.presets || []);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderPresets(presets) {
|
||||
const el = document.getElementById('presets-list');
|
||||
if (!presets.length) {
|
||||
el.innerHTML = '<p class="text-sm text-muted">Aucun preset sauvegardé.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = presets.map(p => `
|
||||
<div class="preset-card">
|
||||
<div class="preset-info">
|
||||
<div class="preset-name">${esc(p.label || p.name)}</div>
|
||||
<div class="preset-meta">
|
||||
${p.action_count} action(s)
|
||||
${p.description ? ' · ' + esc(p.description) : ''}
|
||||
${p.saved_at ? ' · ' + esc(p.saved_at.replace('T',' ')) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" onclick="restorePreset('${esc(p.name)}', '${esc(p.label||p.name)}')">↩ Restaurer</button>
|
||||
<button class="btn btn-danger btn-xs" onclick="deletePreset('${esc(p.name)}', '${esc(p.label||p.name)}')">✕</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function savePreset() {
|
||||
const name = document.getElementById('preset-name').value.trim();
|
||||
const desc = document.getElementById('preset-desc').value.trim();
|
||||
if (!name) { showToast('Saisissez un nom de preset', 'error'); return; }
|
||||
try {
|
||||
const r = await api('POST', '/api/actions/presets', { name, description: desc });
|
||||
showToast(`✅ Preset « ${name} » sauvegardé (${r.action_count} actions)`, 'success');
|
||||
document.getElementById('preset-name').value = '';
|
||||
document.getElementById('preset-desc').value = '';
|
||||
loadPresets();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function restorePreset(name, label) {
|
||||
if (!confirm(`Restaurer le preset « ${label} » ?\nLes actions actuelles seront remplacées.`)) return;
|
||||
try {
|
||||
const r = await api('POST', `/api/actions/presets/${encodeURIComponent(name)}/restore`);
|
||||
showToast(`✅ Preset « ${label} » restauré (${r.action_count} actions)`, 'success');
|
||||
showRestartBtn();
|
||||
await loadAll();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePreset(name, label) {
|
||||
if (!confirm(`Supprimer le preset « ${label} » ?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/api/actions/presets/${encodeURIComponent(name)}`);
|
||||
showToast(`🗑 Preset « ${label} » supprimé`, 'info');
|
||||
loadPresets();
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// ── Utilitaires ───────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// ── Restart photobooth-app ────────────────────────────────────────────────────
|
||||
function showRestartBtn() {
|
||||
document.getElementById('restart-btn').style.display = '';
|
||||
}
|
||||
|
||||
async function restartPhotobooth() {
|
||||
const btn = document.getElementById('restart-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Redémarrage…';
|
||||
try {
|
||||
await api('POST', '/api/system/restart-photobooth');
|
||||
showToast('🔄 photobooth-app redémarre — patientez ~10s', 'info', 8000);
|
||||
btn.style.display = 'none';
|
||||
} catch(e) {
|
||||
showToast('❌ Erreur redémarrage: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Redémarrer photobooth-app';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="/admin/actions">Actions</a>
|
||||
<a href="/admin/print">Impression</a>
|
||||
<a href="/admin/settings">Réglages</a>
|
||||
<a href="/admin/events">Événements</a>
|
||||
<a href="/admin/logout">Déconnexion</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -141,12 +142,19 @@
|
||||
<div class="card-title">Services</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>Service</th><th>Statut</th></tr></thead>
|
||||
<thead><tr><th>Service</th><th>Statut</th><th>Action</th></tr></thead>
|
||||
<tbody id="services-tbody">
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td>{{ svc.label }}</td>
|
||||
<td><span class="badge {{ 'badge-success' if svc.active else 'badge-error' }}">{{ 'Actif' if svc.active else 'Arrêté' }}</span></td>
|
||||
<td>
|
||||
{% if svc.name == 'jh-photomaton' %}
|
||||
<button class="btn btn-ghost btn-sm" onclick="restartSelf()">🔄 Restart</button>
|
||||
{% else %}
|
||||
<button class="btn btn-ghost btn-sm" onclick="restartService('{{ svc.name }}', '{{ svc.label }}')">🔄 Restart</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -154,7 +162,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions système -->
|
||||
<div class="section">
|
||||
<div class="card-title">Actions système</div>
|
||||
<div class="card flex gap-2" style="flex-wrap:wrap;align-items:center">
|
||||
<button class="btn btn-primary btn-sm" onclick="restartSelf()">🔄 Redémarrer JH-Photomaton</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="rebootPi()">⚡ Redémarrer le Raspberry Pi</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Overlay reconnexion -->
|
||||
<div id="reconnect-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:none;align-items:center;justify-content:center;flex-direction:column;gap:1rem">
|
||||
<div style="color:#fff;font-size:1.2rem;font-weight:600" id="reconnect-msg">Redémarrage en cours…</div>
|
||||
<div style="color:#aaa;font-size:.9rem">Reconnexion automatique dans quelques secondes…</div>
|
||||
<div class="spinner" style="width:40px;height:40px;border:4px solid #444;border-top-color:#4f9cf9;border-radius:50%;animation:spin 1s linear infinite"></div>
|
||||
</div>
|
||||
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -323,6 +348,50 @@ async function saveEvent(isNew) {
|
||||
} catch(err) { showToast('Erreur sauvegarde événement', 'error'); }
|
||||
}
|
||||
|
||||
// ── Restart services ─────────────────────────────────────────────────────────
|
||||
async function restartService(name, label) {
|
||||
if (!confirm(`Redémarrer ${label} ?\nLe service sera temporairement indisponible.`)) return;
|
||||
try {
|
||||
await api('POST', `/api/system/service/${name}/restart`);
|
||||
showToast(`🔄 ${label} redémarre…`, 'info');
|
||||
} catch(e) { showToast('Erreur restart ' + name, 'error'); }
|
||||
}
|
||||
|
||||
async function restartSelf() {
|
||||
if (!confirm('Redémarrer JH-Photomaton ?\nVous serez déconnecté quelques secondes.')) return;
|
||||
try {
|
||||
await api('POST', '/api/system/restart-self');
|
||||
showReconnectOverlay('Redémarrage de JH-Photomaton…');
|
||||
} catch(e) { showToast('Erreur restart', 'error'); }
|
||||
}
|
||||
|
||||
async function rebootPi() {
|
||||
if (!confirm('⚡ ATTENTION : Redémarrer le Raspberry Pi ?\n\nTout sera arrêté (photobooth, WiFi, impression).\nLe démarrage prend environ 30 secondes.\n\nConfirmer le redémarrage ?')) return;
|
||||
try {
|
||||
await api('POST', '/api/system/reboot');
|
||||
showReconnectOverlay('Redémarrage du Raspberry Pi…');
|
||||
} catch(e) { showToast('Erreur reboot', 'error'); }
|
||||
}
|
||||
|
||||
function showReconnectOverlay(msg) {
|
||||
const overlay = document.getElementById('reconnect-overlay');
|
||||
document.getElementById('reconnect-msg').textContent = msg;
|
||||
overlay.style.display = 'flex';
|
||||
// Attendre 5s puis commencer à sonder /api/system/ping
|
||||
setTimeout(pollReconnect, 5000);
|
||||
}
|
||||
|
||||
async function pollReconnect() {
|
||||
try {
|
||||
const r = await fetch('/api/system/ping');
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* serveur pas encore là */ }
|
||||
setTimeout(pollReconnect, 2000);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
loadQueue();
|
||||
loadEvent();
|
||||
@@ -330,3 +399,4 @@ setInterval(loadQueue, 15000);
|
||||
setInterval(loadEvent, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
| ||||