Module relais USB HID : pilotage projecteurs + alim Canon + eclairage auto

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 15:45:03 +02:00
parent 3aa3068127
commit a6766f529e
4 changed files with 349 additions and 2 deletions

View File

@@ -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)."""

166
backend/relais.py Normal file
View File

@@ -0,0 +1,166 @@
"""Pilotage module relais USB HID (LCUS 5131:2007).
4 canaux, protocole LCUS :
ON : 0x00 0xA0 <ch> 0x01 <checksum> 0x00 0x00 0x00 0x00
OFF : 0x00 0xA0 <ch> 0x00 <checksum> 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