"""API de gestion des actions photobooth-app + mapping bouton + presets.""" import copy import json import logging import re from datetime import datetime from pathlib import Path from fastapi import APIRouter, Request, Body from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) router = APIRouter() # ── Action par défaut ──────────────────────────────────────────────────────── DEFAULT_ACTION: dict = { "name": "Nouvelle action", "jobcontrol": {"countdown_capture": 5.0}, "processing": { "remove_background": False, "fill_background_enable": False, "fill_background_color": "#ededed", "img_background_enable": False, "img_background_file": None, "image_filter": "original", "img_frame_enable": False, "img_frame_file": None, "texts_enable": False, "texts": [], }, "trigger": { "ui_trigger": { "show_button": False, "title": "Nouvelle action", "icon": "Photo", "use_custom_color": False, "custom_color": "#016911", }, "keyboard_trigger": {"keycode": ""}, "gpio_trigger": {"pin": "", "trigger_on": "pressed"}, }, } def _presets_dir(request: Request) -> Path: base = Path(request.app.state.config_service._path).parent.parent / "data" / "action_presets" base.mkdir(parents=True, exist_ok=True) return base # ── Lecture ─────────────────────────────────────────────────────────────────── @router.get("/actions/photobooth") async def get_pb_actions(request: Request): pb = request.app.state.photobooth_service cfg = request.app.state.config pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) for i, action in enumerate(actions): action["_index"] = i return {"actions": actions, "button_mapping": cfg.button_actions} @router.get("/actions/assets") async def get_assets(request: Request): pb = request.app.state.photobooth_service frames = await pb.list_userdata_frames() backgrounds = await pb.list_userdata_backgrounds() return {"frames": frames, "backgrounds": backgrounds} # ── CRUD actions ────────────────────────────────────────────────────────────── @router.post("/actions/photobooth") async def create_action(request: Request, body: dict = Body(default={})): """Crée une nouvelle action vide.""" pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.setdefault("actions", {}).setdefault("image", []) new_action = copy.deepcopy(DEFAULT_ACTION) if body.get("name"): new_action["name"] = body["name"] actions.append(new_action) await pb.write_pb_config(pb_config) idx = len(actions) - 1 logger.info("Nouvelle action créée: index %d, nom=%s", idx, new_action["name"]) return {"ok": True, "index": idx, "action": new_action} @router.post("/actions/photobooth/{index}/clone") async def clone_action(request: Request, index: int): """Clone une action (insérée juste après l'original).""" pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) if index < 0 or index >= len(actions): return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) cloned = copy.deepcopy(actions[index]) cloned.pop("_index", None) cloned["name"] = f"{cloned.get('name', 'Action')} (copie)" actions.insert(index + 1, cloned) await pb.write_pb_config(pb_config) logger.info("Action %d clonée → %d: %s", index, index + 1, cloned["name"]) return {"ok": True, "index": index + 1, "action": cloned} @router.delete("/actions/photobooth/{index}") async def delete_action(request: Request, index: int): """Supprime une action.""" pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) if index < 0 or index >= len(actions): return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) if len(actions) <= 1: return JSONResponse({"error": "Impossible de supprimer la dernière action"}, status_code=400) removed = actions.pop(index) await pb.write_pb_config(pb_config) logger.info("Action %d supprimée: %s", index, removed.get("name")) return {"ok": True, "removed_name": removed.get("name")} @router.put("/actions/photobooth/reorder") async def reorder_actions(request: Request, order: list = Body(...)): """Réordonne les actions. Body: [ancien_idx_0, ancien_idx_1, …]""" pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) if sorted(order) != list(range(len(actions))): return JSONResponse({"error": "Ordre invalide"}, status_code=400) pb_config["actions"]["image"] = [actions[i] for i in order] await pb.write_pb_config(pb_config) logger.info("Actions réordonnées: %s", order) return {"ok": True, "order": order} @router.put("/actions/photobooth/{index}") async def update_pb_action(request: Request, index: int, updates: dict = Body(...)): """Met à jour une action (tous les champs sauf GPIO/keyboard trigger). Champs plats acceptés: name, countdown_capture, img_frame_enable, img_frame_file, img_background_enable, img_background_file, fill_background_enable, fill_background_color, remove_background, image_filter, texts_enable, ui_show_button, ui_title, ui_icon, ui_use_custom_color, ui_custom_color """ pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) if index < 0 or index >= len(actions): return JSONResponse({"error": f"Index {index} invalide"}, status_code=400) action = actions[index] if "name" in updates: action["name"] = updates["name"] if "countdown_capture" in updates: action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"]) proc = action.setdefault("processing", {}) for key in ("img_frame_enable", "img_frame_file", "img_background_enable", "img_background_file", "fill_background_enable", "fill_background_color", "remove_background", "image_filter", "texts_enable"): if key in updates: proc[key] = updates[key] ui = action.setdefault("trigger", {}).setdefault("ui_trigger", {}) for key in ("show_button", "title", "icon", "use_custom_color", "custom_color"): if f"ui_{key}" in updates: ui[key] = updates[f"ui_{key}"] pb_config["actions"]["image"][index] = action await pb.write_pb_config(pb_config) logger.info("Action %d mise à jour: %s", index, list(updates.keys())) return {"ok": True, "index": index, "action": action} # ── Déclenchement ───────────────────────────────────────────────────────────── @router.post("/actions/trigger/{index}") async def trigger_action(request: Request, index: int): """Déclenche une action directement (test).""" pb = request.app.state.photobooth_service led = request.app.state.led_service btn = request.app.state.button_service btn.relay_off() try: result = await pb.trigger_image_action(index) return {"ok": True, "index": index, "result": result} except Exception as e: led.play("error") btn.relay_on() return JSONResponse({"error": str(e)}, status_code=500) # ── Mapping bouton ──────────────────────────────────────────────────────────── @router.put("/actions/mapping") async def update_button_mapping(request: Request, mapping: dict = Body(...)): """Body: { "1": {"label": "…", "photobooth_index": 0}, … }""" config_svc = request.app.state.config_service for k, v in mapping.items(): if not isinstance(v, dict) or "photobooth_index" not in v: return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400) config_svc.save_button_actions(mapping) request.app.state.config.button_actions = mapping request.app.state.button_service._btn_actions = mapping logger.info("Mapping bouton mis à jour: %s", mapping) return {"ok": True, "mapping": mapping} # ── Presets ─────────────────────────────────────────────────────────────────── @router.get("/actions/presets") async def list_presets(request: Request): pdir = _presets_dir(request) presets = [] for f in sorted(pdir.glob("*.json")): try: data = json.loads(f.read_text(encoding="utf-8")) presets.append({ "name": f.stem, "label": data.get("label", f.stem), "description": data.get("description", ""), "action_count": len(data.get("actions", [])), "saved_at": data.get("saved_at", ""), }) except Exception: pass return {"presets": presets} @router.post("/actions/presets") async def save_preset(request: Request, body: dict = Body(...)): """Sauvegarde les actions actuelles comme preset.""" label = body.get("name", "").strip() description = body.get("description", "").strip() if not label: return JSONResponse({"error": "Nom requis"}, status_code=400) safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", label).strip("_") if not safe_name: return JSONResponse({"error": "Nom invalide"}, status_code=400) pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() actions = pb_config.get("actions", {}).get("image", []) clean = [{k: v for k, v in copy.deepcopy(a).items() if k != "_index"} for a in actions] preset = { "label": label, "description": description, "saved_at": datetime.now().isoformat(timespec="seconds"), "actions": clean, } (_presets_dir(request) / f"{safe_name}.json").write_text( json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8" ) logger.info("Preset sauvegardé: %s (%d actions)", safe_name, len(clean)) return {"ok": True, "name": safe_name, "action_count": len(clean)} @router.post("/actions/presets/{name}/restore") async def restore_preset(request: Request, name: str): """Restaure un preset (remplace les actions actuelles).""" path = _presets_dir(request) / f"{name}.json" if not path.exists(): return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404) try: preset = json.loads(path.read_text(encoding="utf-8")) except Exception as e: return JSONResponse({"error": f"Lecture preset: {e}"}, status_code=500) actions = preset.get("actions", []) if not actions: return JSONResponse({"error": "Preset vide"}, status_code=400) pb = request.app.state.photobooth_service pb_config = await pb.read_pb_config() pb_config.setdefault("actions", {})["image"] = actions await pb.write_pb_config(pb_config) logger.info("Preset restauré: %s (%d actions)", name, len(actions)) return {"ok": True, "name": name, "action_count": len(actions)} @router.delete("/actions/presets/{name}") async def delete_preset(request: Request, name: str): path = _presets_dir(request) / f"{name}.json" if not path.exists(): return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404) path.unlink() logger.info("Preset supprimé: %s", name) return {"ok": True, "name": name}