184 lines
6.5 KiB
Python
184 lines
6.5 KiB
Python
# 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
|