# trigger_loop.py import asyncio import threading import time from pathlib import Path from Core import settings as config from Core.Logger import Logs from Server.capture_service import capture_with_replay_sync, replay_timing_profile sanad_logger = Logs() sanad_logger.LogEngine("G1_Logs", "trigger_loop") async def _play_prompt(voice, prompt_key: str, fallback_text: str) -> bool: try: return bool(await voice.play_prompt_key(prompt_key, fallback_text)) except Exception as e: sanad_logger.print_and_log(f"⚠️ Prompt playback failed for {prompt_key}: {e}", message_type="warning") return False async def _play_capture_prompt(voice, prompt_key: str, fallback_text: str) -> bool: try: return bool( await voice.play_prompt_key( prompt_key, fallback_text="", allow_gemini_fallback=False, mode_override="audio", ) ) except Exception as e: sanad_logger.print_and_log(f"⚠️ Capture prompt playback failed for {prompt_key}: {e}", message_type="warning") return False async def _play_manual_capture_prompt_sequence( voice, shot_delay_sec: float, cancel_event: threading.Event, ): start_ts = time.time() await _play_capture_prompt( voice, "countdown_intro", "Look at the camera, stay ready, hold your pose with me, keep still, keep your smile soft, and in a moment I will count down for the photo.", ) announced = set() while True: if cancel_event.is_set(): raise asyncio.CancelledError remaining = max(0.0, shot_delay_sec - (time.time() - start_ts)) sec_left = int(round(remaining)) if sec_left in (3, 2, 1) and sec_left not in announced: announced.add(sec_left) await _play_capture_prompt(voice, f"count_{sec_left}", f"{sec_left}...") elif sec_left <= 0 and 0 not in announced: announced.add(0) await _play_capture_prompt(voice, "smile", "Smile.") return await asyncio.sleep(0.05) async def trigger_loop( hub, replay, voice, take_photo_sync_callable=None, ws=None, ): """ R2+X: - play the fixed capture prompt sequence (recorded audio by default) - run unified capture pipeline (replay + timed capture/upload) R2+L1: - cancels pending delayed photo (replay cancel handled in replay engine) Debounce: - must release R2+X to re-trigger - blocks retrigger for PHOTO_TOTAL_SEC window """ r2x_prev = False waiting_release = False photo_busy_until = 0.0 pending_photo_task: asyncio.Task | None = None pending_audio_task: asyncio.Task | None = None pending_capture_cancel: threading.Event | None = None def _hard_cancel_pressed() -> bool: if hasattr(hub, "hard_cancel_combo"): return bool(hub.hard_cancel_combo()) return bool(hub.combo_r2l1()) async def delayed_photo(delay_sec: float, cancel_event: threading.Event): # Unified pipeline: replay + trigger/fallback capture + upload touch. base_prefix = None # keep existing default naming behavior photo_result = await asyncio.to_thread( capture_with_replay_sync, replay_runner=replay, replay_file=config.REPLAY_FILE, home_file=config.HOME_FILE, delay_sec=delay_sec, prefix=base_prefix, speed=1.0, cancel_event=cancel_event, ) sanad_logger.print_and_log(f"📸 Capture pipeline result: {photo_result}", message_type="info") if cancel_event.is_set(): return if isinstance(photo_result, str) and not photo_result.startswith("[ERR]"): await _play_capture_prompt( voice, "photo_saved_thanks", "Thank you. Photo saved. Do not forget to check your photos.", ) while True: await asyncio.sleep(0.02) if not hub.first_state: continue # Cancel combo: cancel pending photo if _hard_cancel_pressed(): if pending_capture_cancel is not None: pending_capture_cancel.set() if pending_photo_task and not pending_photo_task.done(): pending_photo_task.cancel() if pending_audio_task and not pending_audio_task.done(): pending_audio_task.cancel() sanad_logger.print_and_log("🛑 HARD CANCEL: pending photo cancelled (R2+L1).", message_type="warning") r2x_now = hub.combo_r2x() # Require release before next trigger if waiting_release: if not r2x_now: waiting_release = False r2x_prev = r2x_now continue # Do not retrigger while replay is playing if replay.is_playing: r2x_prev = r2x_now continue now = time.time() # Rising edge trigger + not inside busy window if r2x_now and (not r2x_prev) and (now >= photo_busy_until): waiting_release = True # 1) schedule manual countdown speech using the same prompt keys as AI mode if pending_photo_task and not pending_photo_task.done(): if pending_capture_cancel is not None: pending_capture_cancel.set() pending_photo_task.cancel() if pending_audio_task and not pending_audio_task.done(): pending_audio_task.cancel() timing = replay_timing_profile(config.REPLAY_FILE) delay = float(timing.get("capture_offset_sec") or max(0.0, min(config.PHOTO_DELAY_SEC, config.PHOTO_TOTAL_SEC))) replay_duration = float(timing.get("duration_sec") or 0.0) photo_busy_until = now + max(config.PHOTO_TOTAL_SEC, replay_duration + 3.2, delay + config.PHOTO_THANKS_SEC) sanad_logger.print_and_log( "🎯 Manual capture timing: " f"replay={Path(config.REPLAY_FILE).name} " f"duration={replay_duration:.3f}s " f"shot_at={delay:.3f}s " f"source={timing.get('capture_source', 'config_fallback')}", message_type="info", ) pending_capture_cancel = threading.Event() pending_audio_task = asyncio.create_task( _play_manual_capture_prompt_sequence(voice, delay, pending_capture_cancel) ) pending_photo_task = asyncio.create_task(delayed_photo(delay, pending_capture_cancel)) r2x_prev = r2x_now