From 8a70dea240d0cc958434638c85556f52237b8139 Mon Sep 17 00:00:00 2001 From: jbperrin Date: Fri, 17 Jul 2026 17:08:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20d=C3=A9tection=20erreurs=20imprimante?= =?UTF-8?q?=20via=20IPP=20direct=20(Selphy=20CP1300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - script_print.sh : après lp, polling CUPS + sondage IPP direct ipp://IP:631/ipp/print Détecte input-tray-missing / media-empty en ~3-6s sans attendre le timeout CUPS - printer_service.py : ajout _get_printer_ip(), _query_ipp_direct() _get_printer_status() utilise IPP pour état/raisons/encre (source de vérité) - Timeout porté à 120s côté Python, fallback lpstat conservé --- Opencode/scripts/script_print.sh | 40 +++++++- backend/services/printer_service.py | 139 ++++++++++++++++++++++---- photobooth-app/script/script_print.sh | 133 ++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 photobooth-app/script/script_print.sh diff --git a/Opencode/scripts/script_print.sh b/Opencode/scripts/script_print.sh index f284a41..fda8668 100644 --- a/Opencode/scripts/script_print.sh +++ b/Opencode/scripts/script_print.sh @@ -136,8 +136,26 @@ if [ -z "$JOB_ID" ]; then exit 0 fi +# --- Preparation du sondage IPP direct sur l'imprimante --- +# La Selphy CP1300 expose un serveur IPP natif (port 631, sans auth). +# Cela permet de detecter les erreurs physiques en quelques secondes +# sans attendre un timeout CUPS (qui ne rapporte pas les erreurs papier). + +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}, ipptool=$(command -v ipptool || echo absent))" +fi + +# Mots-cles d'erreur dans printer-state-reasons +IPP_ERROR_REASONS="input-tray-missing|media-empty|media-needed|marker-supply-empty|cover-open|door-open|offline-report" + # --- Suivi du job CUPS (attend la completion physique) --- -MAX_WAIT=90 # secondes max (Selphy CP1300 ~ 60s) +MAX_WAIT=90 # secondes max (Selphy CP1300 imprime en ~47s) POLL_INTERVAL=3 elapsed=0 @@ -147,7 +165,7 @@ while [ $elapsed -lt $MAX_WAIT ]; do sleep $POLL_INTERVAL elapsed=$((elapsed + POLL_INTERVAL)) - # Succes : job dans la liste des jobs termines + # 1. Succes : job dans la liste des jobs termines par 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}" @@ -155,13 +173,27 @@ while [ $elapsed -lt $MAX_WAIT ]; do exit 0 fi - # Erreur : imprimante stoppee (papier absent, capot ouvert, etc.) + # 2. Sondage IPP direct (detecte papier absent, capot ouvert, etc. en ~3s) + 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 detectee 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 : imprimante stoppee cote CUPS 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 $SELECTED_PRINTER en erreur: ${reason:-stopped} (apres ${elapsed}s)" + log_error "Imprimante $SELECTED_PRINTER 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 diff --git a/backend/services/printer_service.py b/backend/services/printer_service.py index 444fe86..83e32ce 100644 --- a/backend/services/printer_service.py +++ b/backend/services/printer_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +import re import subprocess import time import uuid @@ -258,6 +259,81 @@ class PrinterService: 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: @@ -307,18 +383,17 @@ class PrinterService: return {"stats_done": 0, "stats_error": 0, "stats_cancelled": 0} def _get_printer_status(self, printer_name: str) -> dict: - """Statut CUPS complet pour une imprimante (état, jobs, accepting, uri, modèle).""" + """Statut complet : CUPS pour les jobs, IPP direct pour état/erreurs/encre.""" try: - # État de l'imprimante + # ── 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: @@ -328,18 +403,19 @@ 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 (adresse IP / protocole) + # URI du périphérique uri = "" + printer_ip = "" try: r_uri = subprocess.run( ["lpstat", "-v", printer_name], @@ -350,14 +426,15 @@ class PrinterService: 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, localisation et raisons d'erreur (lpstat -l -p) + # Modèle / location via lpstat -l -p model = "" location = "" reasons: list[str] = [] - state_message = "" try: r_info = subprocess.run( ["lpstat", "-l", "-p", printer_name], @@ -372,24 +449,44 @@ class PrinterService: elif l.lower().startswith("reason:"): raw = l.split(":", 1)[1].strip() if raw and raw.lower() not in ("none", ""): - label = self._REASON_LABELS.get(raw, raw) - reasons.append(label) + reasons.append(self._REASON_LABELS.get(raw, raw)) except Exception: pass - # Niveaux d'encre/ruban via attributs IPP (localhost CUPS) - markers = self._get_marker_levels(printer_name) + # ── 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, + "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)} diff --git a/photobooth-app/script/script_print.sh b/photobooth-app/script/script_print.sh new file mode 100644 index 0000000..216ea18 --- /dev/null +++ b/photobooth-app/script/script_print.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================================= +# script_print.sh - Impression photo pour photomaton +# ============================================================================= +# Usage: script_print.sh "" "" "" "" +# Appele par photobooth-app via l'action Share "Impression" +# +# 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:: en cas de succes +# PRINT_ERROR: en cas d'erreur +# ============================================================================= + +set -uo pipefail + +# --- Parametres --- +filename="${1:-}" +media_type="${2:-}" +action_config_name="${3:-}" +copies="${4:-1}" + +# --- 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"; } +log_error() { logger -t "$LOG_TAG" "[ERROR] $1"; echo "[ERROR] $1"; } + +# --- Validation --- +if [ -z "$filename" ]; then + log_error "Aucun fichier specifie" + exit 1 +fi + +if [ ! -f "$filename" ]; then + log_error "Fichier introuvable: $filename" + exit 1 +fi + +if [ "$copies" -lt 1 ] || [ "$copies" -gt 3 ]; then + log_error "Nombre de copies invalide: $copies (doit etre entre 1 et 3)" + exit 1 +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 convert "$filename" \ + -resize 2000x1333 \ + -background black \ + -gravity center \ + -extent ${PRINT_EXTENT} \ + -roll ${PRINT_ROLL} \ + -quality 95 \ + "$filename_print" 2>/dev/null; then + FILE_TO_PRINT="$filename_print" + log_info "Image preparee: $filename_print" + else + log_error "Echec de la preparation, impression de l'original" + FILE_TO_PRINT="$filename" + 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") + +SELECTED_PRINTER="" + +if [[ "$printer_1_status" == *"idle"* ]]; then + SELECTED_PRINTER="$PRINTER_1" + log_info "Imprimante selectionnee: $PRINTER_1 (idle)" +elif [[ "$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 + 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)" +fi + +# --- Impression --- +log_info "Impression x${copies} sur $SELECTED_PRINTER: $FILE_TO_PRINT" + +# Envoi du job CUPS et capture du job ID +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}" + +# Si le job ID est introuvable, comportement legacy (retour immediat) +if [ -z "$JOB_ID" ]; then + log_info "Job ID non extrait – imp \ No newline at end of file