"""Service d'impression — file d'attente SQLite + appel script_print.sh.""" from __future__ import annotations import asyncio import logging import subprocess import time import uuid from pathlib import Path from typing import Literal import aiosqlite from backend.services.config_service import Config logger = logging.getLogger(__name__) PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"] CREATE_SQL = """ CREATE TABLE IF NOT EXISTS print_queue ( id TEXT PRIMARY KEY, filename TEXT NOT NULL, thumb_url TEXT, copies INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', printer TEXT, requested_at REAL, processed_at REAL, error_msg TEXT ); """ class PrinterService: def __init__(self, config: Config): self._cfg = config.print self._db_path: Path | None = None self._db: aiosqlite.Connection | None = None self._lock = asyncio.Lock() async def init_db(self, db_path: Path): db_path.parent.mkdir(parents=True, exist_ok=True) self._db_path = db_path self._db = await aiosqlite.connect(str(db_path)) self._db.row_factory = aiosqlite.Row await self._db.execute(CREATE_SQL) await self._db.commit() logger.info("Base print_queue initialisée: %s", db_path) async def close(self): if self._db: await self._db.close() # ── File d'attente ──────────────────────────────────────────────────────── async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> 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), ) await self._db.commit() entry = { "id": entry_id, "filename": filename, "thumb_url": thumb_url, "copies": copies, "status": "pending", "requested_at": now, } # Mode direct : impression immédiate sans validation if self._cfg.mode == "direct": asyncio.create_task(self.execute_print(entry_id, copies)) logger.info("Demande d'impression ajoutée: %s (%s)", entry_id, filename) return entry async def get_queue(self, status: str | None = None) -> list[dict]: """Liste les entrées de la file d'attente.""" if status: cursor = await self._db.execute( "SELECT * FROM print_queue WHERE status = ? ORDER BY requested_at DESC", (status,), ) else: cursor = await self._db.execute( "SELECT * FROM print_queue ORDER BY requested_at DESC LIMIT 100" ) rows = await cursor.fetchall() return [dict(r) for r in rows] async def get_pending(self) -> list[dict]: return await self.get_queue("pending") async def cancel(self, entry_id: str) -> bool: async with self._lock: cursor = await self._db.execute( "SELECT status FROM print_queue WHERE id = ?", (entry_id,) ) row = await cursor.fetchone() if not row or row["status"] not in ("pending",): return False await self._db.execute( "UPDATE print_queue SET status='cancelled', processed_at=? WHERE id=?", (time.time(), entry_id), ) await self._db.commit() return True # ── Impression ──────────────────────────────────────────────────────────── async def execute_print(self, entry_id: str, copies: int = 1, printer: str = "") -> dict: """Lance l'impression via script_print.sh.""" async with self._lock: cursor = await self._db.execute( "SELECT * FROM print_queue WHERE id = ?", (entry_id,) ) entry = await cursor.fetchone() if not entry: return {"success": False, "error": "Entrée introuvable"} if entry["status"] not in ("pending",): return {"success": False, "error": f"Statut incompatible: {entry['status']}"} await self._db.execute( "UPDATE print_queue SET status='printing', copies=? WHERE id=?", (copies, entry_id), ) await self._db.commit() filename = entry["filename"] script = self._cfg.script_path # Appel async du script d'impression try: result = await asyncio.to_thread( self._run_print_script, script, filename, copies ) except Exception as e: result = {"success": False, "error": str(e), "printer": ""} now = time.time() if result["success"]: await self._db.execute( "UPDATE print_queue SET status='done', printer=?, processed_at=? WHERE id=?", (result.get("printer", ""), now, entry_id), ) else: await self._db.execute( "UPDATE print_queue SET status='error', error_msg=?, processed_at=? WHERE id=?", (result.get("error", ""), now, entry_id), ) await self._db.commit() logger.info("Impression %s: %s", entry_id, "OK" if result["success"] else result.get("error")) return result def _run_print_script(self, script: str, filename: str, copies: int) -> dict: """Appelle script_print.sh de façon synchrone.""" if not Path(script).exists(): return {"success": False, "error": f"Script introuvable: {script}"} if not Path(filename).exists(): return {"success": False, "error": f"Fichier introuvable: {filename}"} cmd = ["/bin/bash", script, filename, "image", "default", str(copies)] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=60 ) 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": ""} except subprocess.TimeoutExpired: return {"success": False, "error": "Timeout impression (60s)"} except Exception as e: return {"success": False, "error": str(e)} # ── Statut imprimantes CUPS ─────────────────────────────────────────────── async def get_printers_status(self) -> list[dict]: """Retourne le statut des imprimantes CUPS configurées.""" statuses = [] for p in self._cfg.printers: status = await asyncio.to_thread(self._get_printer_status, p["name"]) statuses.append({ "name": p["name"], "label": p["label"], **status, }) return statuses def _get_printer_status(self, printer_name: str) -> dict: try: result = subprocess.run( ["lpstat", "-p", printer_name], capture_output=True, text=True, timeout=5 ) output = result.stdout.lower() if "idle" in output: state = "idle" elif "printing" in output or "processing" in output: state = "printing" elif "disabled" in output: state = "disabled" elif "not found" in output or result.returncode != 0: state = "offline" else: state = "unknown" # Compte les jobs en attente jobs_result = subprocess.run( ["lpstat", "-o", printer_name], capture_output=True, text=True, timeout=5 ) jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l]) return {"state": state, "jobs": jobs} except Exception as e: return {"state": "error", "jobs": 0, "error": str(e)} async def cancel_cups_jobs(self, printer_name: str) -> bool: """Annule tous les jobs CUPS pour une imprimante.""" try: result = subprocess.run( ["cancel", "-a", printer_name], capture_output=True, text=True, timeout=10 ) return result.returncode == 0 except Exception as e: logger.error("Erreur cancel CUPS: %s", e) return False