first commit
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-16 00:55:29 +02:00
commit 209f81aa3d
100 changed files with 17682 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
"""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