feat: détection erreurs imprimante via IPP direct (Selphy CP1300)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

- 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é
This commit is contained in:
2026-07-17 17:08:00 +02:00
parent 68b0a36031
commit 8a70dea240
3 changed files with 287 additions and 25 deletions
+36 -4
View File
@@ -136,8 +136,26 @@ if [ -z "$JOB_ID" ]; then
exit 0 exit 0
fi 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) --- # --- 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 POLL_INTERVAL=3
elapsed=0 elapsed=0
@@ -147,7 +165,7 @@ while [ $elapsed -lt $MAX_WAIT ]; do
sleep $POLL_INTERVAL sleep $POLL_INTERVAL
elapsed=$((elapsed + 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 if lpstat -W completed 2>/dev/null | grep -q "^${JOB_ID} "; then
log_info "Job $JOB_ID termine avec succes (${elapsed}s)" log_info "Job $JOB_ID termine avec succes (${elapsed}s)"
echo "PRINTED:${SELECTED_PRINTER}:${copies}" echo "PRINTED:${SELECTED_PRINTER}:${copies}"
@@ -155,13 +173,27 @@ while [ $elapsed -lt $MAX_WAIT ]; do
exit 0 exit 0
fi 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) printer_state=$(lpstat -p "$SELECTED_PRINTER" 2>/dev/null || true)
if echo "$printer_state" | grep -qiE "stopped|disabled"; then if echo "$printer_state" | grep -qiE "stopped|disabled"; then
reason=$(lpstat -l -p "$SELECTED_PRINTER" 2>/dev/null \ reason=$(lpstat -l -p "$SELECTED_PRINTER" 2>/dev/null \
| grep -i "Reason:" | head -1 \ | grep -i "Reason:" | head -1 \
| sed 's/.*Reason:[[:space:]]*//' | xargs) | 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 cancel "$JOB_ID" 2>/dev/null || true
echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason:-stopped}" echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason:-stopped}"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null [ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
+109 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
import subprocess import subprocess
import time import time
import uuid import uuid
@@ -258,6 +259,81 @@ class PrinterService:
except Exception as e: except Exception as e:
return {"success": False, "error": str(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 ─────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────
def _printer_name(self, p) -> str: def _printer_name(self, p) -> str:
@@ -307,18 +383,17 @@ class PrinterService:
return {"stats_done": 0, "stats_error": 0, "stats_cancelled": 0} return {"stats_done": 0, "stats_error": 0, "stats_cancelled": 0}
def _get_printer_status(self, printer_name: str) -> dict: 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: try:
# État de l'imprimante # ── Etat de base via lpstat ──
r_state = subprocess.run( r_state = subprocess.run(
["lpstat", "-p", printer_name], ["lpstat", "-p", printer_name],
capture_output=True, text=True, timeout=5 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(): if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower():
return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0} return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0}
out = r_state.stdout.lower()
if "idle" in out: if "idle" in out:
state = "idle" state = "idle"
elif "printing" in out or "processing" in out: elif "printing" in out or "processing" in out:
@@ -328,18 +403,19 @@ class PrinterService:
else: else:
state = "unknown" 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( r_accept = subprocess.run(
["lpstat", "-a", printer_name], ["lpstat", "-a", printer_name],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
accepting = "accepting" in r_accept.stdout.lower() accepting = "accepting" in r_accept.stdout.lower()
# Liste des jobs CUPS en cours # Jobs CUPS en cours
jobs = self._get_cups_jobs(printer_name) jobs = self._get_cups_jobs(printer_name)
# URI du périphérique (adresse IP / protocole) # URI du périphérique
uri = "" uri = ""
printer_ip = ""
try: try:
r_uri = subprocess.run( r_uri = subprocess.run(
["lpstat", "-v", printer_name], ["lpstat", "-v", printer_name],
@@ -350,14 +426,15 @@ class PrinterService:
parts = line.split(":", 2) parts = line.split(":", 2)
uri = (parts[1].strip() + ":" + parts[2].strip()) if len(parts) > 2 else "" uri = (parts[1].strip() + ":" + parts[2].strip()) if len(parts) > 2 else ""
break break
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", r_uri.stdout)
printer_ip = m.group(1) if m else ""
except Exception: except Exception:
pass pass
# Modèle, localisation et raisons d'erreur (lpstat -l -p) # Modèle / location via lpstat -l -p
model = "" model = ""
location = "" location = ""
reasons: list[str] = [] reasons: list[str] = []
state_message = ""
try: try:
r_info = subprocess.run( r_info = subprocess.run(
["lpstat", "-l", "-p", printer_name], ["lpstat", "-l", "-p", printer_name],
@@ -372,12 +449,32 @@ class PrinterService:
elif l.lower().startswith("reason:"): elif l.lower().startswith("reason:"):
raw = l.split(":", 1)[1].strip() raw = l.split(":", 1)[1].strip()
if raw and raw.lower() not in ("none", ""): if raw and raw.lower() not in ("none", ""):
label = self._REASON_LABELS.get(raw, raw) reasons.append(self._REASON_LABELS.get(raw, raw))
reasons.append(label)
except Exception: except Exception:
pass pass
# Niveaux d'encre/ruban via attributs IPP (localhost CUPS) # ── 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) markers = self._get_marker_levels(printer_name)
return { return {
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# =============================================================================
# script_print.sh - Impression photo pour photomaton
# =============================================================================
# Usage: script_print.sh "<filename>" "<media_type>" "<action_config_name>" "<copies>"
# 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:<printer_name>:<copies> en cas de succes
# PRINT_ERROR:<printer_name> 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