# capture_service.py import os import subprocess import threading import time from pathlib import Path from Core.error_events import record_error from Core import settings as config from Server import direct_camera_client SERVER_DIR = Path(__file__).resolve().parent PROJECT_ROOT = SERVER_DIR.parent FIX_SCRIPT = str((PROJECT_ROOT / "Scripts" / "fix_realsense_usb.sh").resolve()) UPLOAD_NOW_FLAG = (PROJECT_ROOT / "Scripts" / "upload_now.flag").resolve() def _touch_upload_now_flag(): try: UPLOAD_NOW_FLAG.parent.mkdir(parents=True, exist_ok=True) UPLOAD_NOW_FLAG.write_text(str(time.time()), encoding="utf-8") except Exception as e: record_error("capture_service", "touch_upload_now_flag", e) def _next_photo_name(out_dir: Path, prefix: str, ext: str) -> Path: base = out_dir / f"{prefix}.{ext}" if not base.exists(): return base i = 1 while True: p = out_dir / f"{prefix}({i}).{ext}" if not p.exists(): return p i += 1 def take_photo_sync(prefix: str | None = None) -> str: """ Captures one photo and returns the saved path or a compact error. Production runtime uses the direct camera service only. """ retry_count = max(0, int(config.read_watchdog_camera_capture_retry_count())) retry_delay = max(0.0, float(config.read_watchdog_camera_capture_retry_delay_sec())) total_attempts = max(1, retry_count + 1) last_err = "[ERR] take_photo unknown error" for attempt in range(1, total_attempts + 1): try: env = os.environ.copy() env.setdefault("PHOTOS_DIR", str(Path(config.PHOTOS_DIR).resolve())) if prefix: env["PHOTO_PREFIX"] = prefix else: env.setdefault("PHOTO_PREFIX", "photo") if not direct_camera_client.is_enabled(): last_err = "[ERR] direct camera service is disabled" record_error( "capture_service", "take_photo_sync_attempt_failed", context={"attempt": attempt, "total_attempts": total_attempts, "reason": "direct_camera_disabled"}, ) continue saved = direct_camera_client.capture( prefix=env.get("PHOTO_PREFIX", "photo"), ext=env.get("PHOTO_EXT", "jpg").strip().lstrip(".") or "jpg", timeout=10.0, ) _touch_upload_now_flag() return saved except Exception as e: last_err = f"[ERR] take_photo exception: {e}" record_error( "capture_service", "take_photo_sync_exception", e, {"attempt": attempt, "total_attempts": total_attempts}, ) if attempt < total_attempts and retry_delay > 0.0: time.sleep(retry_delay) return last_err def take_photo_test_sync() -> str: """ Takes a photo and deletes it immediately. Good as a camera health test. Returns "OK" or an error string. """ result = take_photo_sync() if result.startswith("/"): try: p = Path(result) if p.exists(): p.unlink() return "OK" except Exception as e: return f"[WARN] captured but delete failed: {e}" return result def run_fix_realsense_sync() -> str: """ Runs fix_realsense_usb.sh. NOTE: script uses sudo tee -> needs passwordless sudo to fully work. """ try: if not Path(FIX_SCRIPT).exists(): return f"[ERR] fix script not found: {FIX_SCRIPT}" p = subprocess.run( ["bash", FIX_SCRIPT], capture_output=True, text=True, timeout=20, ) out = (p.stdout or "").strip() err = (p.stderr or "").strip() if p.returncode == 0: return out or "OK" return f"[ERR] fix script rc={p.returncode}\n{out}\n{err}".strip() except Exception as e: record_error("capture_service", "run_fix_realsense_sync_exception", e) return f"[ERR] fix exception: {e}" def replay_file_integrity(path: Path) -> dict: """ Checks: - file exists - has at least one frame with 'q' - has at least one trigger marker (meta contains trigger) """ p = Path(path).resolve() out = { "ok": False, # usable replay (has frames) "trigger_ok": False, # has explicit trigger markers "path": str(p), "exists": False, "frames": 0, "triggers": 0, "error": "", } if not p.exists() or not p.is_file(): out["error"] = "missing replay file" return out out["exists"] = True import json try: with p.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: d = json.loads(line) except Exception: continue if isinstance(d, dict) and isinstance(d.get("q"), list): out["frames"] += 1 meta = d.get("meta") trig = False if isinstance(meta, str) and ("trigger" in meta.lower()): trig = True elif isinstance(meta, dict) and bool(meta.get("trigger")): trig = True if trig: out["triggers"] += 1 out["ok"] = out["frames"] > 0 out["trigger_ok"] = out["triggers"] > 0 if out["frames"] <= 0: out["error"] = "replay has no valid frames" elif out["triggers"] <= 0: out["warning"] = "replay has no trigger markers; timed fallback capture will be used" return out except Exception as e: out["error"] = str(e) record_error("capture_service", "replay_file_integrity", e, {"path": str(p)}) return out def replay_timing_profile(path: Path, end_margin_sec: float | None = None) -> dict: """ Returns replay timing information used to align countdown audio and fallback capture. If the replay contains explicit trigger markers, the first trigger offset is treated as the desired shot time. Otherwise the shot is scheduled slightly before the replay ends so capture happens before the arm starts returning home. """ p = Path(path).resolve() margin = max(0.0, float(end_margin_sec if end_margin_sec is not None else config.REPLAY_CAPTURE_END_MARGIN_SEC)) out = { "ok": False, "path": str(p), "exists": False, "frames": 0, "timed_frames": 0, "duration_sec": 0.0, "trigger_offsets_sec": [], "trigger_count": 0, "capture_offset_sec": max(0.0, min(config.PHOTO_DELAY_SEC, config.PHOTO_TOTAL_SEC)), "capture_source": "config_fallback", "end_margin_sec": margin, "error": "", } if not p.exists() or not p.is_file(): out["error"] = "missing replay file" return out out["exists"] = True import json timed_offsets: list[float] = [] trigger_offsets: list[float] = [] first_t: float | None = None try: with p.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: d = json.loads(line) except Exception: continue if not (isinstance(d, dict) and isinstance(d.get("q"), list)): continue out["frames"] += 1 try: t_val = float(d.get("t")) except Exception: continue if first_t is None: first_t = t_val rel_t = max(0.0, t_val - first_t) timed_offsets.append(rel_t) out["timed_frames"] += 1 meta = d.get("meta") if isinstance(meta, str) and ("trigger" in meta.lower()): trigger_offsets.append(rel_t) elif isinstance(meta, dict) and bool(meta.get("trigger")): trigger_offsets.append(rel_t) out["ok"] = out["frames"] > 0 if not out["ok"]: out["error"] = "replay has no valid frames" return out if timed_offsets: duration_sec = max(0.0, timed_offsets[-1]) out["duration_sec"] = duration_sec out["trigger_offsets_sec"] = [round(x, 3) for x in trigger_offsets] out["trigger_count"] = len(trigger_offsets) if trigger_offsets: out["capture_offset_sec"] = max(0.0, min(duration_sec, trigger_offsets[0])) out["capture_source"] = "trigger" elif duration_sec > 0.0: out["capture_offset_sec"] = max(0.0, duration_sec - margin) out["capture_source"] = "timed_end" else: out["capture_source"] = "config_fallback" else: out["error"] = "replay has no timestamped frames; config fallback timing will be used" return out except Exception as e: out["error"] = str(e) record_error("capture_service", "replay_timing_profile", e, {"path": str(p)}) return out def ensure_replay_integrity(active_replay: Path, fallback_replay: Path) -> tuple[Path, dict]: """ Returns a valid replay path. If active invalid, tries fallback. """ active = Path(active_replay).resolve() fallback = Path(fallback_replay).resolve() active_report = replay_file_integrity(active) if active_report.get("ok") and active_report.get("trigger_ok"): return active, {"selected": str(active), "active": active_report, "fallback_used": False} fallback_report = replay_file_integrity(fallback) if fallback_report.get("ok") and fallback_report.get("trigger_ok"): return fallback, { "selected": str(fallback), "active": active_report, "fallback": fallback_report, "fallback_used": True, "reason": "active replay missing trigger markers", } # Degraded mode: no trigger-marked file found, keep active if it has frames. if active_report.get("ok"): return active, { "selected": str(active), "active": active_report, "fallback": fallback_report, "fallback_used": False, "degraded_no_trigger": True, } # If both invalid, keep active and report both failures. return active, { "selected": str(active), "active": active_report, "fallback": fallback_report, "fallback_used": False, } def capture_with_replay_sync( replay_runner, replay_file: Path, home_file: Path, delay_sec: float, prefix: str | None = None, speed: float = 1.0, finalize_timeout_sec: float = 4.0, cancel_event: threading.Event | None = None, ) -> str: """ Unified capture pipeline: replay + trigger callback capture + fallback timed capture + upload flag. """ delay_sec = max(0.0, float(delay_sec)) done = threading.Event() lock = threading.Lock() result = {"value": None} capture_started = {"value": False} def _is_cancelled() -> bool: return bool(cancel_event is not None and cancel_event.is_set()) def _capture_once(reason: str): if _is_cancelled(): return with lock: if capture_started["value"]: return capture_started["value"] = True try: result["value"] = take_photo_sync(prefix=prefix) except Exception as e: record_error("capture_service", "capture_with_replay_capture_once", e, {"reason": reason}) result["value"] = f"[ERR] capture failed ({reason}): {e}" finally: done.set() if replay_runner is not None and bool(getattr(replay_runner, "is_playing", False)): return "[ERR] replay busy" if _is_cancelled(): return "[ERR] capture cancelled" try: if replay_runner is not None: def _trigger_cb(_frame): threading.Thread(target=lambda: _capture_once("trigger"), daemon=True).start() threading.Thread( target=lambda: replay_runner.run(replay_file, home_file, speed, trigger_callback=_trigger_cb), daemon=True, ).start() except Exception as e: record_error("capture_service", "capture_with_replay_start_replay", e) return f"[ERR] replay start failed: {e}" deadline = time.time() + delay_sec while (not done.is_set()) and (time.time() < deadline): if _is_cancelled(): return "[ERR] capture cancelled" time.sleep(0.02) if (not done.is_set()) and (not _is_cancelled()): _capture_once("fallback") finalize_deadline = time.time() + max(0.1, finalize_timeout_sec) while not done.is_set(): if _is_cancelled(): return "[ERR] capture cancelled" if time.time() >= finalize_deadline: break time.sleep(0.02) if not done.is_set(): record_error("capture_service", "capture_with_replay_timeout", context={"delay_sec": delay_sec}) return "[ERR] capture pipeline timeout" return str(result["value"] or "[ERR] capture pipeline no result")