diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bcdfe96..59aba62 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,14 @@ "Bash(cp -r /home/jules/Documents/Projet/photobooth-new/frontend .)", "Bash(cp -r /home/jules/Documents/Projet/photobooth-new/data .)", "Bash(mkdir -p scripts)", - "Bash(curl:*)" + "Bash(curl:*)", + "Bash(grep -r \"proxmox\\\\|lxc\\\\|pve\\\\|mxc\\\\|container\" /home/jules/Documents/Projet/*/memoire.md)", + "Bash(grep -r \"LXC\\\\|lxc\\\\|proxmox\\\\|pve\" /home/jules/Documents/Projet/*/memoire.md)", + "Bash(grep -r \"proxmox\\\\|pve\\\\|8006\" /home/jules/Documents/Projet/*/memoire.md)", + "Bash(ssh:*)", + "Bash(grep -r \"npm\\\\|nginx\\\\|proxy\\\\|reverse\" /home/jules/Documents/Projet/*/memoire.md)", + "Bash(ssh -i ~/.ssh/id_ed25519 root@192.168.111.4 \"pct exec 112 -- bash -c ''''cat /etc/nginx-proxy-manager/.env 2>/dev/null; ls /opt/npm/ 2>/dev/null; ls /app/ 2>/dev/null; docker ps 2>/dev/null | grep nginx''''\")", + "Bash(ssh -i ~/.ssh/id_ed25519 root@192.168.111.4 \"pct exec 112 -- bash -c ''''a2dissite photobooth && systemctl reload apache2'''' 2>/dev/null\")" ] } } diff --git a/src/main.py b/src/main.py index 340f1be..f76557c 100644 --- a/src/main.py +++ b/src/main.py @@ -5,24 +5,44 @@ import sys from PyQt6.QtWidgets import QApplication from PyQt6.QtCore import Qt from ui.main_window import MainWindow +from ui.widgets.config_dialog import load_config from services.upload_server import UploadServer +from services.imap_watcher import ImapWatcher def main(): app = QApplication(sys.argv) app.setApplicationName("Photostation") + config = load_config() + window = MainWindow() window.setWindowFlags(Qt.WindowType.FramelessWindowHint) # Serveur upload (WiFi / QR Code) - port = 8080 if "--no-root" in sys.argv else 80 - upload_server = UploadServer(port=port) + port = config.get("server_port", 8080) + host = config.get("server_ip", "0.0.0.0") + upload_server = UploadServer(host=host, port=port) upload_server.signals.photos_received.connect( window.home_screen.notify_qr_photos ) upload_server.start() + # IMAP polling (Email) + imap_watcher = ImapWatcher() + if config.get("imap_enabled"): + imap_watcher.signals.photos_received.connect( + window.home_screen.notify_email_photos + ) + imap_watcher.start( + server=config.get("imap_server", ""), + port=config.get("imap_port", 993), + user=config.get("imap_user", ""), + password=config.get("imap_password", ""), + folder=config.get("imap_folder", "INBOX"), + interval=config.get("imap_interval", 30), + ) + if "--kiosk" in sys.argv: screen = app.primaryScreen().geometry() window.setGeometry(screen) diff --git a/src/services/imap_watcher.py b/src/services/imap_watcher.py new file mode 100644 index 0000000..6b3bed7 --- /dev/null +++ b/src/services/imap_watcher.py @@ -0,0 +1,119 @@ +"""Service IMAP polling — consulte une boîte mail et récupère les pièces jointes photos.""" + +import imaplib +import email +import os +import time +import threading +from email.header import decode_header +from PyQt6.QtCore import QObject, pyqtSignal + +UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data", "photos") +ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "bmp", "tiff", "tif", "webp", "heic"} + + +class ImapSignals(QObject): + """Signaux Qt pour communiquer du thread IMAP vers le thread principal.""" + photos_received = pyqtSignal(list) + error = pyqtSignal(str) + + +class ImapWatcher: + """Consulte une boîte IMAP en boucle et récupère les photos en PJ.""" + + def __init__(self): + self.signals = ImapSignals() + self._thread = None + self._running = False + self._seen_uids = set() + + os.makedirs(UPLOAD_DIR, exist_ok=True) + + def start(self, server, port, user, password, folder="INBOX", interval=30): + """Démarre le polling IMAP dans un thread daemon.""" + if not server or not user: + return + + self._running = True + self._config = { + "server": server, + "port": port, + "user": user, + "password": password, + "folder": folder, + "interval": interval, + } + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + + def _poll_loop(self): + """Boucle de polling.""" + while self._running: + try: + self._check_mail() + except Exception as e: + self.signals.error.emit(str(e)) + time.sleep(self._config["interval"]) + + def _check_mail(self): + """Connexion IMAP, récupère les nouveaux mails avec PJ photos.""" + cfg = self._config + mail = imaplib.IMAP4_SSL(cfg["server"], cfg["port"]) + mail.login(cfg["user"], cfg["password"]) + mail.select(cfg["folder"]) + + # Chercher les mails non lus + status, data = mail.search(None, "UNSEEN") + if status != "OK" or not data[0]: + mail.logout() + return + + saved_photos = [] + + for uid in data[0].split(): + if uid in self._seen_uids: + continue + self._seen_uids.add(uid) + + status, msg_data = mail.fetch(uid, "(RFC822)") + if status != "OK": + continue + + msg = email.message_from_bytes(msg_data[0][1]) + + for part in msg.walk(): + content_type = part.get_content_type() + filename = part.get_filename() + + if filename: + # Décoder le nom de fichier + decoded_parts = decode_header(filename) + filename = "" + for content, encoding in decoded_parts: + if isinstance(content, bytes): + filename += content.decode(encoding or "utf-8", errors="replace") + else: + filename += content + + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in ALLOWED_EXTENSIONS: + continue + + # Sauvegarder la pièce jointe + payload = part.get_payload(decode=True) + if not payload: + continue + + safe_name = f"{int(time.time() * 1000)}_{filename}" + path = os.path.join(UPLOAD_DIR, safe_name) + with open(path, "wb") as f: + f.write(payload) + saved_photos.append(path) + + mail.logout() + + if saved_photos: + self.signals.photos_received.emit(saved_photos) diff --git a/src/services/upload_server.py b/src/services/upload_server.py index 2761955..82876e3 100644 --- a/src/services/upload_server.py +++ b/src/services/upload_server.py @@ -275,7 +275,8 @@ class UploadSignals(QObject): class UploadServer: """Serveur Flask d'upload, tourne dans un thread séparé.""" - def __init__(self, port=80): + def __init__(self, host="0.0.0.0", port=80): + self.host = host self.port = port self.signals = UploadSignals() self.app = Flask(__name__) @@ -315,7 +316,7 @@ class UploadServer: """Démarre le serveur dans un thread daemon.""" self._thread = threading.Thread( target=lambda: self.app.run( - host="0.0.0.0", port=self.port, + host=self.host, port=self.port, debug=False, use_reloader=False ), daemon=True diff --git a/src/ui/widgets/config_dialog.py b/src/ui/widgets/config_dialog.py index de813f2..a065873 100644 --- a/src/ui/widgets/config_dialog.py +++ b/src/ui/widgets/config_dialog.py @@ -4,7 +4,8 @@ import json import os from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, - QLineEdit, QPushButton, QLabel, QComboBox, QScrollArea, QWidget + QLineEdit, QPushButton, QLabel, QComboBox, QScrollArea, QWidget, + QSpinBox, QCheckBox ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont @@ -14,13 +15,25 @@ CONFIG_PATH = os.path.join( ) DEFAULT_CONFIG = { + # WiFi hotspot "wifi_ap_name": "Photostation", "wifi_ap_password": "photo1234", + # Serveur upload + "server_ip": "0.0.0.0", + "server_port": 8080, + "upload_url": "http://192.168.4.1:8080/upload", + # Email IMAP "email_address": "photo@photostation.local", - "upload_url": "http://192.168.4.1/upload", + "imap_enabled": False, + "imap_server": "", + "imap_port": 993, + "imap_user": "", + "imap_password": "", + "imap_folder": "INBOX", + "imap_interval": 30, + # Impression "printer_name": "", "print_format": "10x15 cm", - "kiosk_mode": False, } @@ -52,27 +65,60 @@ class ConfigDialog(QDialog): def showEvent(self, event): super().showEvent(event) - # Plein écran pour masquer l'interface derrière if self.parent(): self.setGeometry(self.parent().rect()) self.setStyleSheet("ConfigDialog { background-color: #f0f0f0; }") - def _fs(self, pct): - """Font size en % de la hauteur du dialogue.""" - return max(8, int(self.height() * pct / 100)) if self.height() > 0 else 14 + def _section_title(self, text): + lbl = QLabel(text) + lbl.setFont(QFont("", 13, QFont.Weight.Bold)) + lbl.setStyleSheet("color: #2196F3; padding-top: 5px;") + return lbl + + def _label(self, text): + lbl = QLabel(text) + lbl.setFont(QFont("", 12)) + lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + return lbl + + def _input(self, value, placeholder=""): + inp = QLineEdit(str(value)) + inp.setFont(QFont("", 12)) + inp.setMinimumHeight(35) + inp.setStyleSheet(""" + QLineEdit { + border: 2px solid #ccc; border-radius: 6px; + padding: 5px 10px; background: white; + } + QLineEdit:focus { border-color: #2196F3; } + """) + if placeholder: + inp.setPlaceholderText(placeholder) + return inp + + def _password_input(self, value): + inp = self._input(value) + inp.setEchoMode(QLineEdit.EchoMode.Password) + return inp + + def _spinbox(self, value, min_val, max_val): + sb = QSpinBox() + sb.setFont(QFont("", 12)) + sb.setMinimumHeight(35) + sb.setRange(min_val, max_val) + sb.setValue(value) + return sb def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(20, 10, 20, 10) layout.setSpacing(8) - # Titre title = QLabel("Configuration Photostation") title.setFont(QFont("", 18, QFont.Weight.Bold)) title.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title) - # Scroll area pour le contenu scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -87,10 +133,7 @@ class ConfigDialog(QDialog): row = 0 # ── WiFi ── - wifi_title = QLabel("WiFi (Hotspot)") - wifi_title.setFont(QFont("", 13, QFont.Weight.Bold)) - wifi_title.setStyleSheet("color: #2196F3; padding-top: 5px;") - grid.addWidget(wifi_title, row, 0, 1, 2) + grid.addWidget(self._section_title("WiFi (Hotspot)"), row, 0, 1, 2) row += 1 grid.addWidget(self._label("Nom du réseau :"), row, 0) @@ -103,35 +146,73 @@ class ConfigDialog(QDialog): grid.addWidget(self.wifi_pass_input, row, 1) row += 1 - # ── QR Code ── - qr_title = QLabel("QR Code") - qr_title.setFont(QFont("", 13, QFont.Weight.Bold)) - qr_title.setStyleSheet("color: #2196F3; padding-top: 5px;") - grid.addWidget(qr_title, row, 0, 1, 2) + # ── Serveur upload ── + grid.addWidget(self._section_title("Serveur upload (QR Code / WiFi)"), row, 0, 1, 2) row += 1 - grid.addWidget(self._label("URL d'upload :"), row, 0) - self.upload_url_input = self._input(self.config["upload_url"]) + grid.addWidget(self._label("IP d'écoute :"), row, 0) + self.server_ip_input = self._input(self.config["server_ip"], "0.0.0.0 = toutes les interfaces") + grid.addWidget(self.server_ip_input, row, 1) + row += 1 + + grid.addWidget(self._label("Port :"), row, 0) + self.server_port_input = self._spinbox(self.config["server_port"], 80, 65535) + grid.addWidget(self.server_port_input, row, 1) + row += 1 + + grid.addWidget(self._label("URL publique (QR) :"), row, 0) + self.upload_url_input = self._input(self.config["upload_url"], "https://photo.exemple.fr/upload") grid.addWidget(self.upload_url_input, row, 1) row += 1 - # ── Email ── - email_title = QLabel("Email") - email_title.setFont(QFont("", 13, QFont.Weight.Bold)) - email_title.setStyleSheet("color: #2196F3; padding-top: 5px;") - grid.addWidget(email_title, row, 0, 1, 2) + # ── Email IMAP ── + grid.addWidget(self._section_title("Email (réception IMAP)"), row, 0, 1, 2) row += 1 - grid.addWidget(self._label("Adresse email :"), row, 0) + grid.addWidget(self._label("Activer :"), row, 0) + self.imap_enabled_cb = QCheckBox("Consulter la boîte mail") + self.imap_enabled_cb.setFont(QFont("", 12)) + self.imap_enabled_cb.setChecked(self.config["imap_enabled"]) + grid.addWidget(self.imap_enabled_cb, row, 1) + row += 1 + + grid.addWidget(self._label("Adresse affichée :"), row, 0) self.email_input = self._input(self.config["email_address"]) grid.addWidget(self.email_input, row, 1) row += 1 + grid.addWidget(self._label("Serveur IMAP :"), row, 0) + self.imap_server_input = self._input(self.config["imap_server"], "imap.gmail.com") + grid.addWidget(self.imap_server_input, row, 1) + row += 1 + + grid.addWidget(self._label("Port IMAP :"), row, 0) + self.imap_port_input = self._spinbox(self.config["imap_port"], 1, 65535) + grid.addWidget(self.imap_port_input, row, 1) + row += 1 + + grid.addWidget(self._label("Utilisateur :"), row, 0) + self.imap_user_input = self._input(self.config["imap_user"]) + grid.addWidget(self.imap_user_input, row, 1) + row += 1 + + grid.addWidget(self._label("Mot de passe :"), row, 0) + self.imap_pass_input = self._password_input(self.config["imap_password"]) + grid.addWidget(self.imap_pass_input, row, 1) + row += 1 + + grid.addWidget(self._label("Dossier :"), row, 0) + self.imap_folder_input = self._input(self.config["imap_folder"], "INBOX") + grid.addWidget(self.imap_folder_input, row, 1) + row += 1 + + grid.addWidget(self._label("Intervalle (sec) :"), row, 0) + self.imap_interval_input = self._spinbox(self.config["imap_interval"], 10, 300) + grid.addWidget(self.imap_interval_input, row, 1) + row += 1 + # ── Impression ── - print_title = QLabel("Impression") - print_title.setFont(QFont("", 13, QFont.Weight.Bold)) - print_title.setStyleSheet("color: #2196F3; padding-top: 5px;") - grid.addWidget(print_title, row, 0, 1, 2) + grid.addWidget(self._section_title("Impression"), row, 0, 1, 2) row += 1 grid.addWidget(self._label("Imprimante :"), row, 0) @@ -151,7 +232,7 @@ class ConfigDialog(QDialog): scroll.setWidget(content) layout.addWidget(scroll, 1) - # Boutons en bas + # Boutons buttons = QHBoxLayout() buttons.setSpacing(15) @@ -177,36 +258,20 @@ class ConfigDialog(QDialog): layout.addLayout(buttons) - def _label(self, text): - lbl = QLabel(text) - lbl.setFont(QFont("", 12)) - lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - return lbl - - def _input(self, value, placeholder=""): - inp = QLineEdit(value) - inp.setFont(QFont("", 12)) - inp.setMinimumHeight(35) - inp.setStyleSheet(""" - QLineEdit { - border: 2px solid #ccc; - border-radius: 6px; - padding: 5px 10px; - background: white; - } - QLineEdit:focus { - border-color: #2196F3; - } - """) - if placeholder: - inp.setPlaceholderText(placeholder) - return inp - def _save(self): self.config["wifi_ap_name"] = self.wifi_name_input.text() self.config["wifi_ap_password"] = self.wifi_pass_input.text() + self.config["server_ip"] = self.server_ip_input.text() + self.config["server_port"] = self.server_port_input.value() self.config["upload_url"] = self.upload_url_input.text() self.config["email_address"] = self.email_input.text() + self.config["imap_enabled"] = self.imap_enabled_cb.isChecked() + self.config["imap_server"] = self.imap_server_input.text() + self.config["imap_port"] = self.imap_port_input.value() + self.config["imap_user"] = self.imap_user_input.text() + self.config["imap_password"] = self.imap_pass_input.text() + self.config["imap_folder"] = self.imap_folder_input.text() + self.config["imap_interval"] = self.imap_interval_input.value() self.config["printer_name"] = self.printer_input.text() self.config["print_format"] = self.format_combo.currentText() save_config(self.config)