Impression Mitsubishi K60 + cadres + interface admin distante
- Intégration selphy_print (backend CUPS dyesub Mitsubishi CP-K60DW-S) - Formats impression : 15x20, 10x15, 10x15 2-strips avec cadre et orientation portrait/paysage - Cadres d'impression par format (strip/10x15/15x20) : upload, sélection, aperçu live - Cadres démo générés : pellicule noir/vintage/couleurs + bordures classique/doré/rose gold - Écran de choix de cadre avant capture (clic direct, aperçu grand format) - Filtres réduits à 3 (Couleur, N&B, Sépia) et appliqués sur tous les strips - Port 80 via authbind, route /admin avec redirection auto depuis poste distant - Autologin LightDM corrigé (pam-autologin-service) - Bouton évacuer bourrage papier dans admin - Fix : _gp_lock manquant dans Camera.__init__ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,81 +1,265 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import charger_config
|
||||
from backend.effects import appliquer_cadre_impression
|
||||
|
||||
log = logging.getLogger("photobooth.printer")
|
||||
|
||||
# Essayer d'importer cups
|
||||
try:
|
||||
import cups
|
||||
CUPS_DISPONIBLE = True
|
||||
except ImportError:
|
||||
CUPS_DISPONIBLE = False
|
||||
log.warning("pycups non installe, impression indisponible")
|
||||
# PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S
|
||||
# Le resize vers ces dimensions exactes évite toute bordure blanche
|
||||
FORMATS = {
|
||||
"10x15": {"cups": "w288h432", "px": (1200, 1800)},
|
||||
"15x15": {"cups": "w432h432", "px": (1800, 1800)},
|
||||
"15x20": {"cups": "w432h576", "px": (1800, 2400)},
|
||||
"10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800)}, # 1 strip 5x15cm, printer duplique
|
||||
"15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400)}, # 1 strip 7.5x20cm, printer duplique
|
||||
}
|
||||
|
||||
ERREUR_FICHIER = "fichier_introuvable"
|
||||
ERREUR_IMPRIMANTE = "imprimante_indisponible"
|
||||
ERREUR_BOURRAGE = "bourrage_papier"
|
||||
ERREUR_PAPIER = "plus_de_papier"
|
||||
ERREUR_IMPRESSION = "erreur_impression"
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", "timeout"
|
||||
except FileNotFoundError:
|
||||
return -1, "", f"commande introuvable : {cmd[0]}"
|
||||
|
||||
|
||||
def preparer_apercu(chemin: Path, largeur: int, hauteur: int,
|
||||
orientation: str = "paysage") -> Path:
|
||||
"""
|
||||
Génère un aperçu JPEG recadré aux bonnes dimensions (pour le frontend).
|
||||
orientation : 'portrait' pivote le DSLR paysage de 90° avant recadrage.
|
||||
"""
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(chemin).convert("RGB")
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if orientation == "portrait" and img.width > img.height:
|
||||
img = img.rotate(90, expand=True)
|
||||
img = _crop_to(img, largeur, hauteur)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
img.save(tmp.name, "JPEG", quality=85)
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
def _crop_to(img, largeur: int, hauteur: int):
|
||||
"""Cover crop centre vers les dimensions exactes."""
|
||||
from PIL import Image
|
||||
iw, ih = img.size
|
||||
ratio_cible = largeur / hauteur
|
||||
ratio_src = iw / ih
|
||||
if ratio_src > ratio_cible:
|
||||
new_w = int(ih * ratio_cible)
|
||||
left = (iw - new_w) // 2
|
||||
img = img.crop((left, 0, left + new_w, ih))
|
||||
elif ratio_src < ratio_cible:
|
||||
new_h = int(iw / ratio_cible)
|
||||
top = (ih - new_h) // 2
|
||||
img = img.crop((0, top, iw, top + new_h))
|
||||
return img.resize((largeur, hauteur), Image.LANCZOS)
|
||||
|
||||
|
||||
def _preparer_image(chemin: Path, largeur: int, hauteur: int,
|
||||
format_papier: str | None = None, cadre: str | None = None,
|
||||
orientation: str = "paysage") -> Path:
|
||||
"""
|
||||
Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
|
||||
applique le cadre, sauvegarde en temp.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except ImportError:
|
||||
log.warning("PIL absent — image envoyée telle quelle, risque de bordures")
|
||||
return chemin
|
||||
|
||||
img = Image.open(chemin).convert("RGB")
|
||||
img = ImageOps.exif_transpose(img) # respecte l'orientation EXIF
|
||||
|
||||
# Portrait demandé mais DSLR paysage → rotation 90°
|
||||
if orientation == "portrait" and img.width > img.height:
|
||||
img = img.rotate(90, expand=True)
|
||||
# Paysage demandé mais image portrait (ex. prise avec DSLR tourné) → rotation -90°
|
||||
elif orientation == "paysage" and img.height > img.width:
|
||||
img = img.rotate(-90, expand=True)
|
||||
|
||||
img = _crop_to(img, largeur, hauteur)
|
||||
|
||||
if cadre and format_papier:
|
||||
img = appliquer_cadre_impression(img, format_papier, cadre)
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
|
||||
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
def _statut(nom: str) -> str:
|
||||
"""Retourne 'idle', 'printing', 'stopped' ou 'inconnue'."""
|
||||
code, out, _ = _run(["lpstat", "-p", nom])
|
||||
if code != 0:
|
||||
return "inconnue"
|
||||
out = out.lower()
|
||||
if "disabled" in out or "stopped" in out:
|
||||
return "stopped"
|
||||
if "printing" in out or "processing" in out:
|
||||
return "printing"
|
||||
if "idle" in out or "inactive" in out:
|
||||
return "idle"
|
||||
return "inconnue"
|
||||
|
||||
|
||||
def _reactiver(nom: str) -> bool:
|
||||
"""Réactive l'imprimante via cupsenable (sudoers sans mot de passe requis)."""
|
||||
code, _, err = _run(["sudo", "cupsenable", nom], timeout=5)
|
||||
if code == 0:
|
||||
log.info(f"Imprimante {nom} réactivée")
|
||||
return True
|
||||
log.error(f"Impossible de réactiver {nom} : {err}")
|
||||
return False
|
||||
|
||||
|
||||
def _detecter_erreur_physique(nom: str) -> str | None:
|
||||
"""Interroge l'état physique via le backend dyesub."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/lib/cups/backend/gutenprint53+usb", "-s"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = (r.stdout + r.stderr).lower()
|
||||
except Exception:
|
||||
return None
|
||||
if "jam" in combined:
|
||||
return ERREUR_BOURRAGE
|
||||
if "media out" in combined or "no media" in combined or "remaining: 0/" in combined:
|
||||
return ERREUR_PAPIER
|
||||
return None
|
||||
|
||||
|
||||
def lister_imprimantes() -> list[dict]:
|
||||
"""Liste les imprimantes disponibles via CUPS."""
|
||||
if not CUPS_DISPONIBLE:
|
||||
"""Liste les imprimantes CUPS disponibles."""
|
||||
code, out, _ = _run(["lpstat", "-p"])
|
||||
if code != 0 or not out.strip():
|
||||
return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
|
||||
|
||||
try:
|
||||
conn = cups.Connection()
|
||||
imprimantes = conn.getPrinters()
|
||||
return [
|
||||
{
|
||||
"nom": nom,
|
||||
"statut": "prete" if info.get("printer-state") == 3 else "occupee",
|
||||
"info": info.get("printer-info", ""),
|
||||
}
|
||||
for nom, info in imprimantes.items()
|
||||
]
|
||||
except cups.IPPError as e:
|
||||
log.error(f"Erreur CUPS : {e}")
|
||||
return []
|
||||
imprimantes = []
|
||||
for ligne in out.splitlines():
|
||||
if not ligne.startswith("printer "):
|
||||
continue
|
||||
parties = ligne.split()
|
||||
if len(parties) < 2:
|
||||
continue
|
||||
nom = parties[1]
|
||||
arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
|
||||
imprimantes.append({
|
||||
"nom": nom,
|
||||
"statut": "arretee" if arretee else "prete",
|
||||
})
|
||||
return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
|
||||
|
||||
|
||||
def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool:
|
||||
"""Imprime une photo sur l'imprimante configuree."""
|
||||
def imprimer(
|
||||
chemin_photo: Path,
|
||||
imprimante: str | None = None,
|
||||
copies: int | None = None,
|
||||
format_papier: str | None = None,
|
||||
cadre_override: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Imprime une photo sans bordure blanche.
|
||||
L'image est recadrée et redimensionnée aux dimensions exactes (300 DPI)
|
||||
avant envoi à CUPS.
|
||||
|
||||
Retourne {"succes": True} ou {"succes": False, "erreur": CODE, "message": str}
|
||||
"""
|
||||
config = charger_config()
|
||||
conf_imp = config.get("impression", {})
|
||||
|
||||
if imprimante is None:
|
||||
imprimante = conf_imp.get("imprimante")
|
||||
imprimante = conf_imp.get("imprimante", "Mitsubishi")
|
||||
if copies is None:
|
||||
copies = conf_imp.get("copies", 1)
|
||||
if format_papier is None:
|
||||
format_papier = conf_imp.get("format", "15x20")
|
||||
|
||||
fmt = FORMATS.get(format_papier, FORMATS["15x20"])
|
||||
page_size = fmt["cups"]
|
||||
largeur, hauteur = fmt["px"]
|
||||
|
||||
fmt_base = format_papier.replace("-2up", "")
|
||||
cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base)
|
||||
orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait")
|
||||
|
||||
if not chemin_photo.exists():
|
||||
log.error(f"Fichier introuvable : {chemin_photo}")
|
||||
return False
|
||||
return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
|
||||
|
||||
if not CUPS_DISPONIBLE:
|
||||
log.info(f"[Simulation] Impression de {chemin_photo} x{copies}")
|
||||
return True
|
||||
chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation)
|
||||
tmp_cree = chemin_print != chemin_photo
|
||||
|
||||
if _statut(imprimante) == "stopped":
|
||||
log.warning(f"Imprimante {imprimante} arrêtée — réactivation")
|
||||
if not _reactiver(imprimante):
|
||||
return {
|
||||
"succes": False,
|
||||
"erreur": ERREUR_IMPRIMANTE,
|
||||
"message": "Imprimante arrêtée, réactivation impossible (vérifiez sudo cupsenable)",
|
||||
}
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
conn = cups.Connection()
|
||||
for tentative in range(1, 4):
|
||||
cmd = [
|
||||
"lp",
|
||||
"-d", imprimante,
|
||||
"-n", str(copies),
|
||||
"-o", f"PageSize={page_size}",
|
||||
"-o", "StpiShrinkOutput=Crop",
|
||||
str(chemin_print),
|
||||
]
|
||||
|
||||
if imprimante is None:
|
||||
imprimante = conn.getDefault()
|
||||
if imprimante is None:
|
||||
imprimantes = conn.getPrinters()
|
||||
if imprimantes:
|
||||
imprimante = list(imprimantes.keys())[0]
|
||||
else:
|
||||
log.error("Aucune imprimante trouvee")
|
||||
return False
|
||||
code, out, err = _run(cmd, timeout=30)
|
||||
|
||||
options = {
|
||||
"copies": str(copies),
|
||||
"media": conf_imp.get("format", "10x15"),
|
||||
"fit-to-page": "true",
|
||||
if code == 0:
|
||||
job = out.strip()
|
||||
log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})")
|
||||
return {"succes": True, "job": job}
|
||||
|
||||
log.warning(f"Impression échouée (tentative {tentative}/3) : {err.strip()}")
|
||||
|
||||
if tentative < 3:
|
||||
if _statut(imprimante) == "stopped":
|
||||
erreur = _detecter_erreur_physique(imprimante)
|
||||
if erreur == ERREUR_BOURRAGE:
|
||||
return {
|
||||
"succes": False,
|
||||
"erreur": ERREUR_BOURRAGE,
|
||||
"message": "Bourrage papier — retirez le papier coincé puis réessayez",
|
||||
}
|
||||
if erreur == ERREUR_PAPIER:
|
||||
return {
|
||||
"succes": False,
|
||||
"erreur": ERREUR_PAPIER,
|
||||
"message": "Plus de papier — rechargez le rouleau",
|
||||
}
|
||||
_reactiver(imprimante)
|
||||
time.sleep(2)
|
||||
|
||||
return {
|
||||
"succes": False,
|
||||
"erreur": ERREUR_IMPRESSION,
|
||||
"message": "Impression échouée après 3 tentatives",
|
||||
}
|
||||
|
||||
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options)
|
||||
log.info(f"Impression lancee : job #{job_id} sur {imprimante}")
|
||||
return True
|
||||
|
||||
except cups.IPPError as e:
|
||||
log.error(f"Erreur impression : {e}")
|
||||
return False
|
||||
finally:
|
||||
if tmp_cree:
|
||||
chemin_print.unlink(missing_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user