369 lines
14 KiB
Python
369 lines
14 KiB
Python
"""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 get_pending_by_photo_id(self) -> dict[str, list]:
|
|
"""Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives.
|
|
|
|
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.
|
|
"""
|
|
rows = await self.get_queue("pending")
|
|
# Aussi inclure celles "printing" (en cours d'impression)
|
|
rows += await self.get_queue("printing")
|
|
|
|
result: dict[str, list] = {}
|
|
for r in rows:
|
|
from pathlib import Path
|
|
stem = Path(r["filename"]).stem
|
|
result.setdefault(stem, []).append(r)
|
|
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é."""
|
|
pending = await self.get_queue("pending")
|
|
from pathlib import Path
|
|
cancelled = 0
|
|
for r in pending:
|
|
if Path(r["filename"]).stem == photo_stem:
|
|
ok = await self.cancel(r["id"])
|
|
if ok:
|
|
cancelled += 1
|
|
return cancelled
|
|
|
|
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)}
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
def _printer_name(self, p) -> str:
|
|
"""Accepte une entrée printers qui soit un str ou un dict."""
|
|
return p["name"] if isinstance(p, dict) else str(p)
|
|
|
|
def _printer_label(self, p) -> str:
|
|
return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p)
|
|
|
|
# ── Statut imprimantes CUPS ───────────────────────────────────────────────
|
|
|
|
async def get_printers_status(self) -> list[dict]:
|
|
"""Retourne le statut détaillé des imprimantes CUPS configurées."""
|
|
statuses = []
|
|
for p in self._cfg.printers:
|
|
name = self._printer_name(p)
|
|
status = await asyncio.to_thread(self._get_printer_status, name)
|
|
statuses.append({
|
|
"name": name,
|
|
"label": self._printer_label(p),
|
|
**status,
|
|
})
|
|
return statuses
|
|
|
|
def _get_printer_status(self, printer_name: str) -> dict:
|
|
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
|
|
try:
|
|
# État de l'imprimante
|
|
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}
|
|
|
|
if "idle" in out:
|
|
state = "idle"
|
|
elif "printing" in out or "processing" in out:
|
|
state = "printing"
|
|
elif "stopped" in out or "disabled" in out:
|
|
state = "disabled"
|
|
else:
|
|
state = "unknown"
|
|
|
|
# Est-ce que l'imprimante accepte les nouveaux jobs ?
|
|
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 = self._get_cups_jobs(printer_name)
|
|
|
|
return {
|
|
"state": state,
|
|
"accepting": accepting,
|
|
"jobs": jobs,
|
|
"jobs_count": len(jobs),
|
|
}
|
|
except Exception as e:
|
|
return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
|
|
|
|
def _get_cups_jobs(self, printer_name: str) -> list[dict]:
|
|
"""Retourne la liste des jobs CUPS en cours pour une imprimante."""
|
|
try:
|
|
r = subprocess.run(
|
|
["lpstat", "-o", printer_name, "-l"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
jobs = []
|
|
current = {}
|
|
for line in r.stdout.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith(printer_name):
|
|
if current:
|
|
jobs.append(current)
|
|
parts = line.split()
|
|
current = {
|
|
"id": parts[0] if parts else "",
|
|
"user": parts[1] if len(parts) > 1 else "",
|
|
"size": parts[3] if len(parts) > 3 else "",
|
|
"date": " ".join(parts[4:7]) if len(parts) > 6 else "",
|
|
"details": [],
|
|
}
|
|
elif current and ":" in line:
|
|
current.setdefault("details", []).append(line)
|
|
if current:
|
|
jobs.append(current)
|
|
return jobs
|
|
except Exception:
|
|
return []
|
|
|
|
async def get_cups_jobs(self, printer_name: str) -> list[dict]:
|
|
return await asyncio.to_thread(self._get_cups_jobs, printer_name)
|
|
|
|
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
|
|
|
|
async def cancel_cups_job(self, job_id: str) -> bool:
|
|
"""Annule un job CUPS précis."""
|
|
try:
|
|
result = subprocess.run(
|
|
["cancel", job_id],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
logger.error("Erreur cancel CUPS job %s: %s", job_id, e)
|
|
return False
|
|
|
|
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)
|
|
return r1.returncode == 0 and r2.returncode == 0
|
|
except Exception as e:
|
|
logger.error("Erreur enable printer %s: %s", printer_name, e)
|
|
return False
|
|
|
|
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)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
logger.error("Erreur disable printer %s: %s", printer_name, e)
|
|
return False
|
|
|
|
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)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
logger.error("Erreur reject printer %s: %s", printer_name, e)
|
|
return False
|