From a6766f529e072821971e1ed57c2b98d4b74b8d7d Mon Sep 17 00:00:00 2001 From: Jules Date: Sun, 2 Aug 2026 15:45:03 +0200 Subject: [PATCH] Module relais USB HID : pilotage projecteurs + alim Canon + eclairage auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pilote backend/relais.py (hidapi/ctypes) pour module LCUS 5131:2007. Relais 1-2 = projecteurs (NO), relais 3 = alim Canon (NF). API : status, on/off par nom, power-cycle, config seuils. Eclairage auto : analyse luminosite visage → allume projecteurs selon seuils. Admin : onglet eclairage enrichi avec boutons relais + seuils configurables. Note : power-cycle Canon par relais ne redémarre pas le DSLR (contact capot batterie). Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 84 ++++++++++++++++++++++ backend/relais.py | 166 +++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 31 +++++++- frontend/js/admin.js | 70 ++++++++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 backend/relais.py diff --git a/backend/main.py b/backend/main.py index 3d0dc60..b71f39a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -31,6 +31,7 @@ from backend.destinations import distribuer_photo, detecter_usb, compteur_restan from backend.printer import lister_imprimantes, imprimer from backend.mailer import envoyer_photo, charger_emails_historique, effacer_emails_historique, ajouter_au_spool, taille_spool, tache_spool_demarrage from backend.qrcode_gen import generer_qr, qr_galerie +from backend import relais from backend.evenements import ( lister_evenements, creer_evenement, obtenir_evenement, modifier_evenement, supprimer_evenement, activer_evenement, @@ -122,6 +123,33 @@ async def _pusher_preview(): eclairage = await loop.run_in_executor(None, analyser_eclairage, donnees) if eclairage is not None: await diffuser_ws({"type": "eclairage", **eclairage}) + _gerer_eclairage_auto(eclairage) + +_proj_etat = {"proj1": False, "proj2": False} + +def _gerer_eclairage_auto(eclairage: dict): + config = charger_config() + rcfg = config.get("relais", {}) + if not rcfg.get("eclairage_auto") or not relais.est_connecte(): + return + score = eclairage.get("score", -1) + if score < 0: + return + seuil_1 = rcfg.get("seuil_proj1", 45) + seuil_2 = rcfg.get("seuil_proj2", 30) + seuil_off = rcfg.get("seuil_off", 65) + if score >= seuil_off and (_proj_etat["proj1"] or _proj_etat["proj2"]): + relais.projecteurs(False) + _proj_etat["proj1"] = False + _proj_etat["proj2"] = False + elif score < seuil_2 and not _proj_etat["proj2"]: + relais.projecteurs(True) + _proj_etat["proj1"] = True + _proj_etat["proj2"] = True + elif score < seuil_1 and not _proj_etat["proj1"]: + relais.activer("projecteur_gauche") + _proj_etat["proj1"] = True + def _usb_reset_canon(): """Reset USB ioctl du Canon EOS (vendor 04a9) pour débloquer un port stall.""" @@ -316,11 +344,13 @@ async def lifespan(app: FastAPI): task_push = asyncio.create_task(_pusher_preview()) task_spool = asyncio.create_task(tache_spool_demarrage()) task_watchdog = asyncio.create_task(_watchdog_systemd()) + relais.connecter() yield task_dslr.cancel() task_push.cancel() task_spool.cancel() task_watchdog.cancel() + relais.deconnecter() log.info("Arret du photobooth") camera.deconnecter() @@ -609,6 +639,60 @@ async def api_eclairage(): return resultat +# --- API Relais --- + +@app.get("/api/relais") +async def api_relais_status(): + return relais.etat_complet() + + +@app.post("/api/relais/{nom}/on") +async def api_relais_on(nom: str): + if nom == "projecteurs": + relais.projecteurs(True) + return {"ok": True} + if not relais.activer(nom): + return JSONResponse({"erreur": f"relais '{nom}' introuvable ou non connecté"}, 400) + return {"ok": True} + + +@app.post("/api/relais/{nom}/off") +async def api_relais_off(nom: str): + if nom == "projecteurs": + relais.projecteurs(False) + return {"ok": True} + if not relais.desactiver(nom): + return JSONResponse({"erreur": f"relais '{nom}' introuvable ou non connecté"}, 400) + return {"ok": True} + + +@app.post("/api/relais/canon/power-cycle") +async def api_power_cycle_canon(req: Request): + body = await req.json() if req.headers.get("content-type", "").startswith("application/json") else {} + duree = body.get("duree", 3.0) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, relais.power_cycle_canon, duree) + return {"ok": True} + + +@app.get("/api/relais/config") +async def api_relais_config(): + config = charger_config() + return config.get("relais", { + "eclairage_auto": False, + "seuil_proj1": 45, + "seuil_proj2": 30, + "seuil_off": 65, + }) + + +@app.post("/api/relais/config") +async def api_relais_config_save(req: Request): + body = await req.json() + mettre_a_jour_config({"relais": body}) + return {"ok": True} + + @app.get("/api/camera/debug") async def api_camera_debug(): """Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2).""" diff --git a/backend/relais.py b/backend/relais.py new file mode 100644 index 0000000..85916ff --- /dev/null +++ b/backend/relais.py @@ -0,0 +1,166 @@ +"""Pilotage module relais USB HID (LCUS 5131:2007). + +4 canaux, protocole LCUS : + ON : 0x00 0xA0 0x01 0x00 0x00 0x00 0x00 + OFF : 0x00 0xA0 0x00 0x00 0x00 0x00 0x00 + Status : 0x00 0xD1 ... + +Assignation : + 1 = Projecteur gauche (NO) + 2 = Projecteur droit (NO) + 3 = Canon alim (NF — OFF=alimenté, ON=coupé) + 4 = Libre +""" + +import ctypes +import ctypes.util +import logging +import threading +import time + +log = logging.getLogger("photobooth.relais") + +VID = 0x5131 +PID = 0x2007 + +CANAUX = { + "projecteur_gauche": {"relay": 1, "mode": "NO"}, + "projecteur_droit": {"relay": 2, "mode": "NO"}, + "canon": {"relay": 3, "mode": "NF"}, +} + +_lib = None +_dev = None +_lock = threading.Lock() + + +def _charger_hidapi(): + global _lib + if _lib is not None: + return _lib + for name in ("hidapi-hidraw", "hidapi-libusb", "hidapi"): + path = ctypes.util.find_library(name) + if path: + _lib = ctypes.CDLL(path) + _lib.hid_init() + _lib.hid_open.restype = ctypes.c_void_p + _lib.hid_read_timeout.restype = ctypes.c_int + log.info(f"hidapi chargé : {path}") + return _lib + try: + _lib = ctypes.CDLL("libhidapi-hidraw.so.0") + _lib.hid_init() + _lib.hid_open.restype = ctypes.c_void_p + _lib.hid_read_timeout.restype = ctypes.c_int + return _lib + except OSError: + log.warning("hidapi introuvable — relais désactivés") + return None + + +def connecter(): + global _dev + lib = _charger_hidapi() + if not lib: + return False + with _lock: + if _dev: + return True + _dev = lib.hid_open(VID, PID, None) + if not _dev: + log.warning("Module relais non trouvé (5131:2007)") + _dev = None + return False + log.info("Module relais connecté") + return True + + +def deconnecter(): + global _dev + with _lock: + if _dev and _lib: + _lib.hid_close(_dev) + _dev = None + + +def est_connecte(): + return _dev is not None + + +def _ecrire(data: list[int]): + if not _dev or not _lib: + return False + buf = (ctypes.c_ubyte * 9)(*data) + with _lock: + res = _lib.hid_write(_dev, buf, 9) + return res > 0 + + +def _relay_set(num: int, on: bool): + state = 0x01 if on else 0x00 + checksum = (0xA0 + num + state) & 0xFF + ok = _ecrire([0x00, 0xA0, num, state, checksum, 0x00, 0x00, 0x00, 0x00]) + if ok: + log.info(f"Relais {num} → {'ON' if on else 'OFF'}") + else: + log.error(f"Échec écriture relais {num}") + return ok + + +def status(): + if not _dev or not _lib: + return None + _ecrire([0x00, 0xD1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + time.sleep(0.1) + rbuf = (ctypes.c_ubyte * 64)() + with _lock: + n = _lib.hid_read_timeout(_dev, rbuf, 64, 500) + if n < 4: + return None + return { + "relay_1": rbuf[1] == 1, + "relay_2": rbuf[2] == 1, + "relay_3": rbuf[3] == 1, + } + + +def activer(nom: str): + cfg = CANAUX.get(nom) + if not cfg: + return False + return _relay_set(cfg["relay"], True) + + +def desactiver(nom: str): + cfg = CANAUX.get(nom) + if not cfg: + return False + return _relay_set(cfg["relay"], False) + + +def projecteurs(on: bool): + _relay_set(1, on) + _relay_set(2, on) + log.info(f"Projecteurs {'allumés' if on else 'éteints'}") + + +def power_cycle_canon(duree: float = 3.0): + log.info(f"Power-cycle Canon ({duree}s)") + _relay_set(3, True) # NF : ON = coupé + time.sleep(duree) + _relay_set(3, False) # NF : OFF = alimenté + log.info("Canon ré-alimenté") + + +def etat_complet(): + raw = status() + if raw is None: + return {"connecte": False} + result = {"connecte": True} + for nom, cfg in CANAUX.items(): + relay_on = raw.get(f"relay_{cfg['relay']}", False) + if cfg["mode"] == "NF": + result[nom] = not relay_on + else: + result[nom] = relay_on + return result diff --git a/frontend/index.html b/frontend/index.html index 232024d..b8dea8c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -383,7 +383,7 @@ - + @@ -890,6 +890,33 @@
--
+

Relais

+
Non connecte
+
+ + + +
+

Eclairage automatique

+
+ +
+
+ + + 1 seul projecteur s'allume +
+
+ + + Les 2 projecteurs s'allument +
+
+ + + Projecteurs s'eteignent +
+
@@ -1057,6 +1084,6 @@ - + diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 2f7da81..828d6d7 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -1823,3 +1823,73 @@ async function sauvegarderSurprise() { }); afficherStatut('Surprise sauvegardee', 'succes'); } + +// === RELAIS === + +async function chargerRelaisStatus() { + try { + const data = await apiGet('/api/relais'); + const el = document.getElementById('relais-status'); + if (!el) return; + if (!data.connecte) { + el.textContent = 'Module relais non connecte'; + el.style.color = '#f44'; + return; + } + const proj_g = data.projecteur_gauche ? '🟢' : '⚫'; + const proj_d = data.projecteur_droit ? '🟢' : '⚫'; + const canon = data.canon ? '🟢' : '🔴'; + el.innerHTML = `Proj G: ${proj_g} | Proj D: ${proj_d} | Canon: ${canon} (alim)`; + el.style.color = '#4caf50'; + } catch (e) { + console.warn('Erreur relais status:', e); + } +} + +async function chargerRelaisConfig() { + try { + const data = await apiGet('/api/relais/config'); + const tog = document.getElementById('tog-eclairage-auto'); + if (tog) tog.checked = !!data.eclairage_auto; + const s1 = document.getElementById('relais-seuil-proj1'); + if (s1) s1.value = data.seuil_proj1 ?? 45; + const s2 = document.getElementById('relais-seuil-proj2'); + if (s2) s2.value = data.seuil_proj2 ?? 30; + const soff = document.getElementById('relais-seuil-off'); + if (soff) soff.value = data.seuil_off ?? 65; + } catch (e) { + console.warn('Erreur relais config:', e); + } +} + +async function sauvegarderRelaisConfig() { + const eclairage_auto = document.getElementById('tog-eclairage-auto')?.checked || false; + const seuil_proj1 = parseInt(document.getElementById('relais-seuil-proj1')?.value) || 45; + const seuil_proj2 = parseInt(document.getElementById('relais-seuil-proj2')?.value) || 30; + const seuil_off = parseInt(document.getElementById('relais-seuil-off')?.value) || 65; + await apiPost('/api/relais/config', { eclairage_auto, seuil_proj1, seuil_proj2, seuil_off }); + afficherStatut('Config relais sauvegardee', 'succes'); +} + +async function relaisAction(nom, action) { + try { + if (nom === 'canon/power-cycle') { + await fetch('/api/relais/canon/power-cycle', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({duree: 3}) + }); + afficherStatut('Power-cycle Canon lance (3s)', 'succes'); + } else { + await fetch(`/api/relais/${nom}/${action}`, { method: 'POST' }); + afficherStatut(`${nom} ${action}`, 'succes'); + } + setTimeout(chargerRelaisStatus, 500); + } catch (e) { + afficherStatut('Erreur relais', 'erreur'); + } +} + +function demarrerEclairageLiveExt() { + chargerRelaisStatus(); + chargerRelaisConfig(); +}