all
🚀 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-17 00:44:22 +02:00
parent 209f81aa3d
commit 9a006f1457
23 changed files with 3712 additions and 484 deletions
+28
View File
@@ -42,6 +42,7 @@ class ButtonService:
self._click_count = 0
self._press_time: float = 0.0
self._click_timer: threading.Timer | None = None
self._long_timer: threading.Timer | None = None # ← fix: init explicite
self._long_press_fired = False
self._relay_enabled = True
self._lock = threading.Lock()
@@ -110,6 +111,33 @@ class ButtonService:
def simulate_long_press(self):
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
def update_timings(
self,
double_click_ms: int | None = None,
long_press_ms: int | None = None,
debounce_ms: int | None = None,
max_clicks: int | None = None,
):
"""Met à jour les timings à chaud (sans redémarrage du service)."""
if double_click_ms is not None:
self._cfg.double_click_ms = double_click_ms
if long_press_ms is not None:
self._cfg.long_press_ms = long_press_ms
if max_clicks is not None:
self._cfg.max_clicks = max(1, min(4, max_clicks))
if debounce_ms is not None:
self._cfg.debounce_ms = debounce_ms
if self._button:
try:
self._button.bounce_time = debounce_ms / 1000
except Exception:
pass
logger.info(
"Timings bouton mis à jour : double_click=%dms, long_press=%dms, debounce=%dms, max_clicks=%d",
self._cfg.double_click_ms, self._cfg.long_press_ms,
self._cfg.debounce_ms, self._cfg.max_clicks,
)
# ── Callbacks GPIO ────────────────────────────────────────────────────────
def _on_pressed(self):
+56 -8
View File
@@ -28,6 +28,7 @@ class PhotoboothConfig:
config_file: str = "/home/pi/.config/photobooth-app/config.json"
media_dir: str = "/home/pi/photobooth-data/media/processed_full"
userdata_dir: str = "/home/pi/photobooth-data/userdata"
show_delete_button: bool = False
@dataclass
@@ -89,6 +90,13 @@ class GalleryConfig:
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
@dataclass
class EventConfig:
name: str = "Evenement"
slug: str = "evenement"
started_at: float = 0.0
@dataclass
class Config:
app: AppConfig = field(default_factory=AppConfig)
@@ -97,6 +105,7 @@ class Config:
leds: LEDConfig = field(default_factory=LEDConfig)
print: PrintConfig = field(default_factory=PrintConfig)
gallery: GalleryConfig = field(default_factory=GalleryConfig)
event: EventConfig = field(default_factory=EventConfig)
button_actions: dict = field(default_factory=dict)
@@ -107,7 +116,7 @@ class ConfigService:
def load(self) -> Config:
if not self.path.exists():
logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
logger.warning("Config introuvable: %s", self.path)
self._config = Config()
return self._config
@@ -143,30 +152,60 @@ class ConfigService:
if "gallery" in raw:
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
if "event" in raw:
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
cfg.button_actions = raw.get("button_actions", {
1: {"label": "Photo normale", "photobooth_index": 0},
2: {"label": "Photo étoile", "photobooth_index": 1},
2: {"label": "Photo etoile", "photobooth_index": 1},
3: {"label": "Photo cailloux", "photobooth_index": 2},
4: {"label": "Photo soirée", "photobooth_index": 3},
4: {"label": "Photo soiree", "photobooth_index": 3},
})
self._config = cfg
logger.info("Configuration chargée depuis %s", self.path)
logger.info("Configuration chargee depuis %s", self.path)
return cfg
def save_button_actions(self, button_actions: dict):
"""Met à jour uniquement la section button_actions dans settings.yaml."""
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw["button_actions"] = button_actions
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
self._config.button_actions = button_actions
def save_show_delete_button(self, visible: bool):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw.setdefault("photobooth", {})["show_delete_button"] = visible
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
self._config.photobooth.show_delete_button = visible
def save_button_config(self, data: dict):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw.setdefault("button", {}).update(data)
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
for k, v in data.items():
if hasattr(self._config.button, k):
setattr(self._config.button, k, v)
def save_event_config(self, name: str, slug: str, started_at: float):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw["event"] = {"name": name, "slug": slug, "started_at": started_at}
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
self._config.event.name = name
self._config.event.slug = slug
self._config.event.started_at = started_at
def save_print_mode(self, mode: str):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
@@ -181,3 +220,12 @@ class ConfigService:
if self._config is None:
self.load()
return self._config
def save_led_effect(self, effect_name: str, effect_data: dict):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw.setdefault("leds", {}).setdefault("effects", {})[effect_name] = effect_data
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
self._config.leds.effects[effect_name] = effect_data
+125
View File
@@ -0,0 +1,125 @@
"""Service de gestion des événements et statistiques.
Chaque événement (soirée, mariage, fête...) a ses propres compteurs :
- photos_taken : incrémenté par le webhook 'finished' de photobooth-app
- print_requests : incrémenté à chaque demande d'impression dans la file
- prints_done : incrémenté à chaque impression réussie
- downloads : incrémenté à chaque téléchargement via /api/gallery/download/
"""
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from pathlib import Path
import aiosqlite
logger = logging.getLogger(__name__)
def slugify(name: str) -> str:
"""Transforme un nom d'événement en slug ASCII sans accents ni espaces."""
# Normalise les caractères Unicode (enlève les accents)
nfkd = unicodedata.normalize("NFD", name)
ascii_str = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
ascii_str = ascii_str.lower()
# Garde lettres, chiffres, espaces, tirets
ascii_str = re.sub(r"[^\w\s-]", "", ascii_str)
# Remplace espaces/tirets multiples par un underscore
ascii_str = re.sub(r"[\s_-]+", "_", ascii_str)
return ascii_str.strip("_") or "evenement"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
started_at REAL NOT NULL,
ended_at REAL,
photos_taken INTEGER NOT NULL DEFAULT 0,
print_requests INTEGER NOT NULL DEFAULT 0,
prints_done INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0
);
"""
_VALID_COUNTERS = {"photos_taken", "print_requests", "prints_done", "downloads"}
class EventService:
def __init__(self):
self._db: aiosqlite.Connection | None = None
async def init_db(self, db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
self._db = await aiosqlite.connect(str(db_path))
self._db.row_factory = aiosqlite.Row
await self._db.execute(_SCHEMA)
await self._db.commit()
logger.info("EventService initialisé : %s", db_path)
async def ensure_event(self, slug: str, name: str, started_at: float):
"""Crée la ligne pour cet événement si elle n'existe pas encore."""
await self._db.execute(
"""INSERT INTO events (name, slug, started_at)
VALUES (?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET name = excluded.name""",
(name, slug, started_at),
)
await self._db.commit()
async def archive_event(self, slug: str):
"""Marque un événement comme terminé (ended_at = maintenant)."""
await self._db.execute(
"UPDATE events SET ended_at = ? WHERE slug = ? AND ended_at IS NULL",
(datetime.now().timestamp(), slug),
)
await self._db.commit()
async def increment(self, slug: str, counter: str):
"""Incrémente un compteur de l'événement identifié par son slug.
Crée automatiquement une ligne si elle n'existe pas.
"""
if counter not in _VALID_COUNTERS:
logger.warning("Compteur inconnu : %s", counter)
return
# Upsert : insère si absent, sinon incrémente
await self._db.execute(
f"""INSERT INTO events (name, slug, started_at, {counter})
VALUES (?, ?, unixepoch(), 1)
ON CONFLICT(slug) DO UPDATE SET {counter} = {counter} + 1""",
(slug, slug),
)
await self._db.commit()
async def get_stats(self, slug: str) -> dict:
"""Retourne les stats pour un slug donné."""
async with self._db.execute(
"""SELECT photos_taken, print_requests, prints_done, downloads
FROM events WHERE slug = ?""",
(slug,),
) as cur:
row = await cur.fetchone()
if not row:
return {"photos_taken": 0, "print_requests": 0, "prints_done": 0, "downloads": 0}
return dict(row)
async def get_history(self) -> list[dict]:
"""Retourne tous les événements triés du plus récent au plus ancien."""
async with self._db.execute(
"""SELECT id, name, slug, started_at, ended_at,
photos_taken, print_requests, prints_done, downloads
FROM events
ORDER BY started_at DESC"""
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def close(self):
if self._db:
await self._db.close()
+61 -67
View File
@@ -1,13 +1,14 @@
"""Service de contrôle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
"""Service de controle de l'anneau LED WS2812b (GPIO18, 35 LEDs).
Mode réel : rpi_ws281x (Raspberry Pi, doit tourner en root ou avec /dev/mem)
Mode mock : log des opérations uniquement (développement)
Mode reel : rpi_ws281x (Raspberry Pi, root ou /dev/mem)
Mode mock : log uniquement (developpement)
"""
from __future__ import annotations
import asyncio
import logging
import math
import threading
import time
from typing import Callable
@@ -16,13 +17,12 @@ from backend.services.config_service import Config, LEDConfig
logger = logging.getLogger(__name__)
# ── Tentative d'import de rpi_ws281x ─────────────────────────────────────────
try:
from rpi_ws281x import PixelStrip, Color as WS_Color, ws
from rpi_ws281x import PixelStrip, Color as WS_Color
HAS_WS281X = True
except ImportError:
HAS_WS281X = False
logger.warning("rpi_ws281x non disponible mode mock LED activé")
logger.warning("rpi_ws281x non disponible -- mode mock LED")
class WS_Color: # type: ignore
def __init__(self, r: int, g: int, b: int):
@@ -31,7 +31,7 @@ except ImportError:
return f"Color({self.r},{self.g},{self.b})"
class PixelStrip: # type: ignore
def __init__(self, *args, **kwargs): pass
def __init__(self, *a, **kw): pass
def begin(self): pass
def show(self): pass
def setPixelColor(self, i, c): pass
@@ -40,16 +40,10 @@ except ImportError:
def _color(rgb: list[int]) -> WS_Color:
return WS_Color(rgb[0], rgb[1], rgb[2])
def _lerp(a: int, b: int, t: float) -> int:
return int(a + (b - a) * t)
return WS_Color(int(rgb[0]), int(rgb[1]), int(rgb[2]))
class LEDService:
"""Contrôle l'anneau LED via un thread dédié + queue de commandes."""
EFFECTS = ("idle", "countdown", "capture", "captured", "finished",
"printing", "error", "disabled", "off")
@@ -74,16 +68,16 @@ class LEDService:
self._cfg.pin,
self._cfg.freq_hz,
self._cfg.dma,
False, # invert
False,
self._cfg.brightness,
0, # channel
0,
)
try:
self._strip.begin()
logger.info("Strip WS2812b initialisé (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
logger.info("Strip WS2812b initialise (%d LEDs, GPIO%d)", self._cfg.count, self._cfg.pin)
except Exception as e:
logger.error("Erreur init strip LED: %s", e)
self._strip = PixelStrip() # fallback mock
self._strip = PixelStrip()
else:
self._strip = PixelStrip()
@@ -104,10 +98,9 @@ class LEDService:
def set_on_change(self, cb: Callable):
self._on_change_cb = cb
# ── Commandes publiques (thread-safe) ────────────────────────────────────
# ── Commandes publiques ───────────────────────────────────────────────────
def play(self, effect: str, countdown_duration: float = 5.0):
"""Joue un effet nommé. Thread-safe."""
with self._lock:
self._current_effect = effect
self._countdown_duration = countdown_duration
@@ -116,22 +109,21 @@ class LEDService:
self._notify_change(effect)
def set_color(self, r: int, g: int, b: int):
"""Couleur fixe immédiate."""
self._fill(WS_Color(r, g, b))
# ── Thread principal ──────────────────────────────────────────────────────
def _run(self):
effect_func = {
"idle": self._effect_idle,
"countdown": self._effect_countdown,
"capture": self._effect_capture,
"captured": self._effect_solid,
"finished": self._effect_finished,
"printing": self._effect_spin,
"error": self._effect_error,
"disabled": self._effect_solid,
"off": self._effect_off,
"idle": self._effect_idle,
"countdown": self._effect_countdown,
"capture": self._effect_capture,
"captured": self._effect_solid,
"finished": self._effect_finished,
"printing": self._effect_spin,
"error": self._effect_error,
"disabled": self._effect_solid,
"off": self._effect_off,
}
while not self._stop_event.is_set():
@@ -143,19 +135,16 @@ class LEDService:
fn = effect_func.get(current, self._effect_idle)
try:
if current in ("countdown",):
if current == "countdown":
fn(cd_dur)
else:
fn()
except Exception as e:
logger.error("Erreur effet LED '%s': %s", current, e)
# Si l'effet s'est terminé naturellement (ex: finished → retour idle)
# on vérifie si un nouveau cmd est arrivé
if not self._cmd_event.is_set() and not self._stop_event.is_set():
with self._lock:
if self._current_effect == current:
# Retour automatique à idle après effets ponctuels
if current in ("capture", "captured", "finished", "error"):
self._current_effect = "idle"
self._cmd_event.wait(timeout=0.1)
@@ -163,27 +152,23 @@ class LEDService:
# ── Effets ───────────────────────────────────────────────────────────────
def _effect_idle(self):
"""Respiration bleue douce."""
cfg = self._cfg.get_effect("idle")
c = cfg.color
speed = cfg.speed
n = self._cfg.count
step = 0
while not self._cmd_event.is_set() and not self._stop_event.is_set():
t = (1 + __import__("math").sin(step * 0.1)) / 2 # 0..1
t = (1 + math.sin(step * 0.1)) / 2
brightness = max(0.05, t)
color = WS_Color(
self._fill(WS_Color(
int(c[0] * brightness),
int(c[1] * brightness),
int(c[2] * brightness),
)
self._fill(color)
))
time.sleep(speed)
step += 1
def _effect_countdown(self, duration: float = 5.0):
"""Remplissage progressif vert LED par LED."""
cfg = self._cfg.get_effect("countdown")
c = cfg.color
n = self._cfg.count
@@ -191,55 +176,68 @@ class LEDService:
color = WS_Color(c[0], c[1], c[2])
self._fill(blank)
t_start = time.time()
while not self._cmd_event.is_set() and not self._stop_event.is_set():
elapsed = time.time() - t_start
ratio = min(elapsed / duration, 1.0)
leds_on = int(ratio * n)
for i in range(n):
self._strip.setPixelColor(i, color if i < leds_on else blank)
self._strip.show()
if ratio >= 1.0:
break
time.sleep(0.04)
def _effect_capture(self):
"""Flash blanc."""
"""Flash photo.
Utilise la couleur configuree dans settings.yaml > leds > effects > capture.
Booste la luminosite au maximum pendant le flash pour maximiser l'eclairage,
puis la restaure a la valeur normale.
Conseil couleur WS2812b : les LEDs bleues sont plus efficaces que les rouges.
Pour un blanc neutre/chaud utilisez par ex. [255, 180, 60] au lieu de [255,255,255].
"""
cfg = self._cfg.get_effect("capture")
white = WS_Color(255, 255, 255)
flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
off = WS_Color(0, 0, 0)
for _ in range(cfg.flashes):
if self._cmd_event.is_set():
break
self._fill(white)
time.sleep(cfg.flash_duration)
# Boost luminosite max pendant le flash
if HAS_WS281X and self._strip:
self._strip.setBrightness(255)
try:
for _ in range(cfg.flashes):
if self._cmd_event.is_set():
break
self._fill(flash_color)
time.sleep(cfg.flash_duration)
self._fill(off)
# Pause inter-flash plus courte que le flash lui-meme
if _ < cfg.flashes - 1:
time.sleep(cfg.flash_duration * 0.4)
finally:
# Toujours restaurer la luminosite normale meme en cas d'erreur
if HAS_WS281X and self._strip:
self._strip.setBrightness(self._cfg.brightness)
self._fill(off)
time.sleep(cfg.flash_duration)
def _effect_solid(self):
"""Couleur pleine selon l'effet courant."""
with self._lock:
current = self._current_effect
cfg = self._cfg.get_effect(current)
self._fill(WS_Color(*cfg.color))
# Attente jusqu'à prochain cmd
self._cmd_event.wait()
def _effect_finished(self):
"""Bleu fixe pendant duration secondes, puis retour idle."""
cfg = self._cfg.get_effect("finished")
self._fill(WS_Color(*cfg.color))
self._cmd_event.wait(timeout=cfg.duration)
def _effect_spin(self):
"""Rotation d'une traînée de LEDs."""
cfg = self._cfg.get_effect("printing")
c = cfg.color
n = self._cfg.count
speed = cfg.speed
tail = 6
pos = 0
@@ -248,22 +246,18 @@ class LEDService:
dist = (i - pos) % n
if dist < tail:
factor = (tail - dist) / tail
self._strip.setPixelColor(
i,
WS_Color(
int(c[0] * factor),
int(c[1] * factor),
int(c[2] * factor),
),
)
self._strip.setPixelColor(i, WS_Color(
int(c[0] * factor),
int(c[1] * factor),
int(c[2] * factor),
))
else:
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
self._strip.show()
pos = (pos + 1) % n
time.sleep(speed)
time.sleep(cfg.speed)
def _effect_error(self):
"""Flash rouge."""
cfg = self._cfg.get_effect("error")
red = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
off = WS_Color(0, 0, 0)
+37
View File
@@ -125,5 +125,42 @@ class PhotoboothService:
results.append(str(f))
return sorted(results)
# ── UI / private.css ──────────────────────────────────────────────────────
_CSS_HIDE_DELETE = """\
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
/* Cacher le bouton Supprimer sur l'écran de review après capture */
.action-button-delete {
display: none !important;
}
"""
_CSS_SHOW_DELETE = """\
/* JH Photomaton — généré automatiquement, ne pas modifier manuellement */
/* Bouton Supprimer visible (activé depuis le dashboard JH Photomaton) */
/* .action-button-delete { display: none !important; } */
"""
async def set_delete_button_visible(self, visible: bool) -> bool:
"""Écrit private.css dans userdata pour afficher ou cacher le bouton Supprimer.
photobooth-app charge automatiquement userdata/private.css à chaque requête
— aucun redémarrage nécessaire, effectif dès la prochaine capture.
"""
css_path = Path(self._cfg.userdata_dir) / "private.css"
try:
css_content = self._CSS_SHOW_DELETE if visible else self._CSS_HIDE_DELETE
css_path.parent.mkdir(parents=True, exist_ok=True)
css_path.write_text(css_content, encoding="utf-8")
logger.info("private.css mis à jour : bouton delete %s", "visible" if visible else "caché")
return True
except Exception as e:
logger.error("Impossible d'écrire private.css : %s", e)
return False
async def get_delete_button_visible(self) -> bool:
"""Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
return self._cfg.show_delete_button
async def close(self):
await self._client.aclose()
+143 -17
View File
@@ -100,6 +100,35 @@ class PrinterService:
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(
@@ -186,48 +215,105 @@ class PrinterService:
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 des imprimantes CUPS configurées."""
"""Retourne le statut détaillé des imprimantes CUPS configurées."""
statuses = []
for p in self._cfg.printers:
status = await asyncio.to_thread(self._get_printer_status, p["name"])
name = self._printer_name(p)
status = await asyncio.to_thread(self._get_printer_status, name)
statuses.append({
"name": p["name"],
"label": p["label"],
"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:
result = subprocess.run(
# État de l'imprimante
r_state = subprocess.run(
["lpstat", "-p", printer_name],
capture_output=True, text=True, timeout=5
)
output = result.stdout.lower()
if "idle" in output:
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 output or "processing" in output:
elif "printing" in out or "processing" in out:
state = "printing"
elif "disabled" in output:
elif "stopped" in out or "disabled" in out:
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],
# Est-ce que l'imprimante accepte les nouveaux jobs ?
r_accept = subprocess.run(
["lpstat", "-a", printer_name],
capture_output=True, text=True, timeout=5
)
jobs = len([l for l in jobs_result.stdout.strip().splitlines() if l])
accepting = "accepting" in r_accept.stdout.lower()
return {"state": state, "jobs": jobs}
# 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", "jobs": 0, "error": str(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."""
@@ -240,3 +326,43 @@ class PrinterService:
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