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
View File
+219
View File
@@ -0,0 +1,219 @@
"""Service de gestion du bouton physique GPIO23 + relay GPIO12.
- Détection multi-clic (1 à 4 clics) avec timer
- Détection long appui (>1500ms)
- Contrôle du relay (désactive/active le bouton 12V)
Mode réel : gpiozero (Raspberry Pi)
Mode mock : aucune action GPIO, simulation possible via API
"""
from __future__ import annotations
import asyncio
import logging
import threading
import time
from backend.services.config_service import Config
logger = logging.getLogger(__name__)
try:
from gpiozero import Button, OutputDevice
HAS_GPIO = True
except (ImportError, Exception):
HAS_GPIO = False
logger.warning("gpiozero non disponible — mode mock bouton activé")
class ButtonService:
def __init__(self, config: Config, led_service, photobooth_service, ws_manager, loop: asyncio.AbstractEventLoop):
self._cfg = config.button
self._btn_actions = config.button_actions
self._led = led_service
self._pb = photobooth_service
self._ws = ws_manager
self._loop = loop
self._button = None
self._relay = None
self._click_count = 0
self._press_time: float = 0.0
self._click_timer: threading.Timer | None = None
self._long_press_fired = False
self._relay_enabled = True
self._lock = threading.Lock()
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self):
if not HAS_GPIO:
logger.info("Mode mock bouton — GPIO non disponible")
return
try:
self._relay = OutputDevice(
self._cfg.relay_pin,
active_high=True,
initial_value=True,
)
self._button = Button(
self._cfg.pin,
pull_up=True,
bounce_time=self._cfg.debounce_ms / 1000,
)
self._button.when_pressed = self._on_pressed
self._button.when_released = self._on_released
logger.info("Bouton GPIO%d, Relay GPIO%d initialisés", self._cfg.pin, self._cfg.relay_pin)
except Exception as e:
logger.error("Erreur init GPIO: %s", e)
def stop(self):
if self._click_timer:
self._click_timer.cancel()
if self._button:
try:
self._button.close()
except Exception:
pass
if self._relay:
try:
self._relay.close()
except Exception:
pass
# ── Relay ─────────────────────────────────────────────────────────────────
def relay_on(self):
"""Active le relay (bouton 12V allumé)."""
self._relay_enabled = True
if self._relay:
self._relay.on()
self._led.play("idle")
logger.debug("Relay ON")
def relay_off(self):
"""Désactive le relay (bouton 12V éteint pendant la capture)."""
self._relay_enabled = False
if self._relay:
self._relay.off()
self._led.play("disabled")
logger.debug("Relay OFF")
# ── Simulation (pour mode mock / tests) ───────────────────────────────────
def simulate_click(self, count: int):
asyncio.run_coroutine_threadsafe(self._handle_click(count), self._loop)
def simulate_long_press(self):
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
# ── Callbacks GPIO ────────────────────────────────────────────────────────
def _on_pressed(self):
self._press_time = time.time()
self._long_press_fired = False
# Lance un timer pour détecter le long appui
if self._click_timer:
self._click_timer.cancel()
long_ms = self._cfg.long_press_ms / 1000
self._long_timer = threading.Timer(long_ms, self._on_long_press_timer)
self._long_timer.start()
def _on_released(self):
if hasattr(self, "_long_timer") and self._long_timer:
self._long_timer.cancel()
if self._long_press_fired:
return # Long press déjà traité
# Compte un clic
with self._lock:
self._click_count += 1
count = self._click_count
if self._click_timer:
self._click_timer.cancel()
if count >= self._cfg.max_clicks:
# Dispatch immédiat si max atteint
self._click_timer = threading.Timer(0.05, self._dispatch_clicks)
else:
# Attente pour éventuel prochain clic
self._click_timer = threading.Timer(
self._cfg.double_click_ms / 1000,
self._dispatch_clicks,
)
self._click_timer.start()
def _on_long_press_timer(self):
self._long_press_fired = True
asyncio.run_coroutine_threadsafe(self._handle_long_press(), self._loop)
def _dispatch_clicks(self):
with self._lock:
count = self._click_count
self._click_count = 0
self._click_timer = None
asyncio.run_coroutine_threadsafe(self._handle_click(count), self._loop)
# ── Handlers async ────────────────────────────────────────────────────────
async def _handle_click(self, count: int):
if count < 1:
return
count = min(count, self._cfg.max_clicks)
logger.info("Bouton: %d clic(s)", count)
# Récupère l'index de l'action photobooth
action_info = self._btn_actions.get(count) or self._btn_actions.get(str(count))
if not action_info:
logger.warning("Aucune action mappée pour %d clic(s)", count)
return
pb_index = action_info.get("photobooth_index", 0)
label = action_info.get("label", f"Action {count}")
await self._ws.broadcast({
"type": "button_event",
"clicks": count,
"action": label,
"photobooth_index": pb_index,
})
# Désactive le relay + LED disabled
self.relay_off()
# Déclenche l'action photobooth-app
try:
await self._pb.trigger_image_action(pb_index)
except Exception as e:
logger.error("Erreur déclenchement action %d: %s", pb_index, e)
self._led.play("error")
await asyncio.sleep(1)
self.relay_on()
async def _handle_long_press(self):
logger.info("Bouton: long appui")
await self._ws.broadcast({"type": "button_event", "clicks": 0, "action": "long_press"})
if not self._cfg.print_enabled:
return
# Déclenche la demande d'impression sur la dernière photo
try:
self._led.play("printing")
await self._pb.trigger_share_latest(0)
except Exception as e:
logger.error("Erreur long press impression: %s", e)
self._led.play("error")
@property
def relay_state(self) -> bool:
return self._relay_enabled
+183
View File
@@ -0,0 +1,183 @@
"""Service de chargement et sauvegarde de la configuration YAML."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
@dataclass
class AppConfig:
name: str = "JH Photomaton"
host: str = "0.0.0.0"
port: int = 8090
debug: bool = False
secret_key: str = ""
admin_password: str = "PhotoBooth2026!"
@dataclass
class PhotoboothConfig:
base_url: str = "http://localhost:8083"
data_dir: str = "/home/pi/photobooth-data"
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"
@dataclass
class ButtonConfig:
pin: int = 23
relay_pin: int = 12
debounce_ms: int = 50
double_click_ms: int = 400
long_press_ms: int = 1500
max_clicks: int = 4
long_press_action: str = "print_last"
print_enabled: bool = True
@dataclass
class LEDEffectConfig:
color: list = field(default_factory=lambda: [0, 30, 80])
mode: str = "solid"
speed: float = 0.05
flashes: int = 2
flash_duration: float = 0.1
duration: float = 2.0
@dataclass
class LEDConfig:
pin: int = 18
count: int = 35
brightness: int = 180
freq_hz: int = 800000
dma: int = 10
strip_type: str = "WS2812"
effects: dict = field(default_factory=dict)
def get_effect(self, name: str) -> LEDEffectConfig:
raw = self.effects.get(name, {})
return LEDEffectConfig(
color=raw.get("color", [0, 30, 80]),
mode=raw.get("mode", "solid"),
speed=raw.get("speed", 0.05),
flashes=raw.get("flashes", 2),
flash_duration=raw.get("flash_duration", 0.1),
duration=raw.get("duration", 2.0),
)
@dataclass
class PrintConfig:
mode: str = "validation"
script_path: str = "/home/pi/photobooth-data/script/script_print.sh"
default_copies: int = 1
printers: list = field(default_factory=list)
@dataclass
class GalleryConfig:
public_enabled: bool = True
photos_per_page: int = 24
qr_base_url: str = "https://photomaton.lessapinsduweb.com"
@dataclass
class Config:
app: AppConfig = field(default_factory=AppConfig)
photobooth: PhotoboothConfig = field(default_factory=PhotoboothConfig)
button: ButtonConfig = field(default_factory=ButtonConfig)
leds: LEDConfig = field(default_factory=LEDConfig)
print: PrintConfig = field(default_factory=PrintConfig)
gallery: GalleryConfig = field(default_factory=GalleryConfig)
button_actions: dict = field(default_factory=dict)
class ConfigService:
def __init__(self, path: Path):
self.path = path
self._config: Config | None = None
def load(self) -> Config:
if not self.path.exists():
logger.warning(f"Config introuvable: {self.path} — utilisation des valeurs par défaut")
self._config = Config()
return self._config
with open(self.path, encoding="utf-8") as f:
raw: dict[str, Any] = yaml.safe_load(f) or {}
cfg = Config()
if "app" in raw:
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if hasattr(AppConfig, k)})
if "photobooth" in raw:
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if hasattr(PhotoboothConfig, k)})
if "button" in raw:
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if hasattr(ButtonConfig, k)})
if "leds" in raw:
led_raw = raw["leds"]
cfg.leds = LEDConfig(
pin=led_raw.get("pin", 18),
count=led_raw.get("count", 35),
brightness=led_raw.get("brightness", 180),
freq_hz=led_raw.get("freq_hz", 800000),
dma=led_raw.get("dma", 10),
strip_type=led_raw.get("strip_type", "WS2812"),
effects=led_raw.get("effects", {}),
)
if "print" in raw:
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if hasattr(PrintConfig, k)})
if "gallery" in raw:
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
cfg.button_actions = raw.get("button_actions", {
1: {"label": "Photo normale", "photobooth_index": 0},
2: {"label": "Photo étoile", "photobooth_index": 1},
3: {"label": "Photo cailloux", "photobooth_index": 2},
4: {"label": "Photo soirée", "photobooth_index": 3},
})
self._config = cfg
logger.info("Configuration chargée 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_print_mode(self, mode: str):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw.setdefault("print", {})["mode"] = mode
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.print.mode = mode
@property
def config(self) -> Config:
if self._config is None:
self.load()
return self._config
+308
View File
@@ -0,0 +1,308 @@
"""Service de contrôle 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)
"""
from __future__ import annotations
import asyncio
import logging
import threading
import time
from typing import Callable
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
HAS_WS281X = True
except ImportError:
HAS_WS281X = False
logger.warning("rpi_ws281x non disponible — mode mock LED activé")
class WS_Color: # type: ignore
def __init__(self, r: int, g: int, b: int):
self.r, self.g, self.b = r, g, b
def __repr__(self):
return f"Color({self.r},{self.g},{self.b})"
class PixelStrip: # type: ignore
def __init__(self, *args, **kwargs): pass
def begin(self): pass
def show(self): pass
def setPixelColor(self, i, c): pass
def numPixels(self): return 35
def setBrightness(self, b): pass
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)
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")
def __init__(self, config: Config):
self._cfg: LEDConfig = config.leds
self._strip: PixelStrip | None = None
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._cmd_event = threading.Event()
self._current_effect: str = "idle"
self._countdown_duration: float = 5.0
self._lock = threading.Lock()
self._on_change_cb: Callable | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self):
if HAS_WS281X:
self._strip = PixelStrip(
self._cfg.count,
self._cfg.pin,
self._cfg.freq_hz,
self._cfg.dma,
False, # invert
self._cfg.brightness,
0, # channel
)
try:
self._strip.begin()
logger.info("Strip WS2812b initialisé (%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
else:
self._strip = PixelStrip()
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True, name="led-service")
self._thread.start()
def stop(self):
self._stop_event.set()
self._cmd_event.set()
if self._thread:
self._thread.join(timeout=2)
self._all_off()
def set_loop(self, loop: asyncio.AbstractEventLoop):
self._loop = loop
def set_on_change(self, cb: Callable):
self._on_change_cb = cb
# ── Commandes publiques (thread-safe) ────────────────────────────────────
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
self._cmd_event.set()
logger.debug("LED effet: %s", effect)
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,
}
while not self._stop_event.is_set():
self._cmd_event.clear()
with self._lock:
current = self._current_effect
cd_dur = self._countdown_duration
fn = effect_func.get(current, self._effect_idle)
try:
if current in ("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)
# ── 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
brightness = max(0.05, t)
color = 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
blank = WS_Color(0, 0, 0)
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."""
cfg = self._cfg.get_effect("capture")
white = WS_Color(255, 255, 255)
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)
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
while not self._cmd_event.is_set() and not self._stop_event.is_set():
for i in range(n):
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),
),
)
else:
self._strip.setPixelColor(i, WS_Color(0, 0, 0))
self._strip.show()
pos = (pos + 1) % n
time.sleep(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)
for _ in range(cfg.flashes):
if self._cmd_event.is_set():
break
self._fill(red)
time.sleep(0.15)
self._fill(off)
time.sleep(0.15)
def _effect_off(self):
self._fill(WS_Color(0, 0, 0))
self._cmd_event.wait()
# ── Helpers ───────────────────────────────────────────────────────────────
def _fill(self, color: WS_Color):
if not HAS_WS281X:
logger.debug("LED mock fill: %s", color)
return
for i in range(self._cfg.count):
self._strip.setPixelColor(i, color)
self._strip.show()
def _all_off(self):
try:
self._fill(WS_Color(0, 0, 0))
except Exception:
pass
def _notify_change(self, effect: str):
if self._on_change_cb and self._loop:
asyncio.run_coroutine_threadsafe(
self._on_change_cb({"type": "led_effect", "effect": effect}),
self._loop,
)
@property
def current_effect(self) -> str:
with self._lock:
return self._current_effect
+129
View File
@@ -0,0 +1,129 @@
"""Client HTTP pour l'API de photobooth-app."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import httpx
from backend.services.config_service import Config
logger = logging.getLogger(__name__)
class PhotoboothService:
def __init__(self, config: Config):
self._cfg = config.photobooth
self._base = self._cfg.base_url.rstrip("/")
self._client = httpx.AsyncClient(base_url=self._base, timeout=10.0)
async def trigger_image_action(self, index: int):
"""Déclenche l'action image à l'index donné."""
r = await self._client.get(f"/api/actions/image/{index}")
r.raise_for_status()
logger.info("Action image %d déclenchée", index)
return r.json() if r.text else {}
async def trigger_share_latest(self, share_index: int = 0):
"""Déclenche l'action de partage (impression) sur la dernière photo."""
r = await self._client.get(f"/api/share/actions/latest/{share_index}")
r.raise_for_status()
logger.info("Share action %d déclenchée", share_index)
return r.json() if r.text else {}
async def get_media_collection(self, limit: int = 200) -> list[dict]:
"""Retourne la liste des photos de la galerie."""
try:
r = await self._client.get("/api/mediacollection/", params={"limit": limit})
r.raise_for_status()
data = r.json()
# photobooth-app retourne soit une liste soit {"items": [...]}
if isinstance(data, list):
return data
return data.get("items", data.get("media_items", []))
except Exception as e:
logger.error("Erreur récupération galerie: %s", e)
return []
async def get_latest_media(self) -> dict | None:
"""Retourne les infos de la dernière photo."""
items = await self.get_media_collection(limit=1)
return items[0] if items else None
async def delete_media(self, media_id: str) -> bool:
"""Supprime une photo via l'API photobooth-app."""
try:
r = await self._client.delete(f"/api/mediacollection/{media_id}")
return r.status_code in (200, 204)
except Exception as e:
logger.error("Erreur suppression %s: %s", media_id, e)
return False
async def is_alive(self) -> bool:
"""Vérifie que photobooth-app répond."""
try:
r = await self._client.get("/api/about", timeout=3.0)
return r.status_code == 200
except Exception:
return False
def media_url(self, identifier: str) -> str:
return f"{self._base}/media/full/{identifier}"
def thumbnail_url(self, identifier: str) -> str:
return f"{self._base}/media/thumbnail/{identifier}"
async def read_pb_config(self) -> dict:
"""Lit le fichier config.json de photobooth-app."""
cfg_path = Path(self._cfg.config_file)
if not cfg_path.exists():
logger.warning("Config photobooth introuvable: %s", cfg_path)
return {}
with open(cfg_path, encoding="utf-8") as f:
return json.load(f)
async def write_pb_config(self, config: dict):
"""Écrit le fichier config.json de photobooth-app."""
cfg_path = Path(self._cfg.config_file)
# Backup avant écriture
backup = cfg_path.with_suffix(f".json_backup_jh")
if cfg_path.exists():
import shutil
shutil.copy2(cfg_path, backup)
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
logger.info("Config photobooth-app mise à jour")
async def list_userdata_frames(self) -> list[str]:
"""Liste les cadres PNG disponibles dans userdata."""
return self._list_files(self._cfg.userdata_dir, "*.png", "frames")
async def list_userdata_backgrounds(self) -> list[str]:
"""Liste les fonds disponibles dans userdata."""
exts = ["*.jpg", "*.jpeg", "*.png"]
files = []
for ext in exts:
files.extend(self._list_files(self._cfg.userdata_dir, ext, "backgrounds"))
return sorted(set(files))
def _list_files(self, base: str, pattern: str, subdir_hint: str) -> list[str]:
base_path = Path(base)
if not base_path.exists():
return []
results = []
for f in base_path.rglob(pattern):
if subdir_hint in f.parts or True: # liste tout
# Chemin relatif depuis data_dir pour passer à photobooth-app
try:
rel = f.relative_to(Path(self._cfg.data_dir))
results.append(str(rel))
except ValueError:
results.append(str(f))
return sorted(results)
async def close(self):
await self._client.aclose()
+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
+117
View File
@@ -0,0 +1,117 @@
"""Service de surveillance des ressources système du Raspberry Pi."""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
logging.warning("psutil non disponible — stats système limitées")
logger = logging.getLogger(__name__)
class SystemService:
def get_stats(self) -> dict:
stats: dict = {}
if not HAS_PSUTIL:
return {"error": "psutil non installé"}
# CPU
stats["cpu_percent"] = psutil.cpu_percent(interval=None)
# RAM
mem = psutil.virtual_memory()
stats["ram_total_mb"] = round(mem.total / 1024 / 1024)
stats["ram_used_mb"] = round(mem.used / 1024 / 1024)
stats["ram_percent"] = mem.percent
stats["ram_available_mb"] = round(mem.available / 1024 / 1024)
# Disque (racine)
disk = psutil.disk_usage("/")
stats["disk_total_gb"] = round(disk.total / 1024 ** 3, 1)
stats["disk_used_gb"] = round(disk.used / 1024 ** 3, 1)
stats["disk_percent"] = disk.percent
# Température CPU (Raspberry Pi)
stats["cpu_temp"] = self._get_cpu_temp()
# Uptime
import time
boot_time = psutil.boot_time()
uptime_sec = int(time.time() - boot_time)
stats["uptime"] = self._format_uptime(uptime_sec)
return stats
def _get_cpu_temp(self) -> float | None:
# Méthode 1 : fichier thermal du Pi
try:
temp_path = Path("/sys/class/thermal/thermal_zone0/temp")
if temp_path.exists():
return round(int(temp_path.read_text().strip()) / 1000, 1)
except Exception:
pass
# Méthode 2 : vcgencmd (Pi OS)
try:
result = subprocess.run(
["vcgencmd", "measure_temp"],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
# output: "temp=47.0'C"
val = result.stdout.strip().replace("temp=", "").replace("'C", "")
return float(val)
except Exception:
pass
# Méthode 3 : psutil sensors (si disponible)
if hasattr(psutil, "sensors_temperatures"):
try:
temps = psutil.sensors_temperatures()
if temps:
first = next(iter(temps.values()))
if first:
return round(first[0].current, 1)
except Exception:
pass
return None
def _format_uptime(self, seconds: int) -> str:
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
if days > 0:
return f"{days}j {hours:02d}h {minutes:02d}m"
return f"{hours:02d}h {minutes:02d}m"
def get_services_status(self) -> list[dict]:
"""Vérifie le statut des services systemd utiles."""
services = [
("photobooth-app", "Photobooth App"),
("jh-photomaton", "JH Photomaton"),
("cups", "CUPS (Impression)"),
]
result = []
for svc_name, label in services:
active = self._is_service_active(svc_name)
result.append({"name": svc_name, "label": label, "active": active})
return result
def _is_service_active(self, service: str) -> bool:
try:
r = subprocess.run(
["systemctl", "is-active", service],
capture_output=True, text=True, timeout=3
)
return r.stdout.strip() == "active"
except Exception:
return False