import copy import json import os from pathlib import Path from typing import Any, Dict PROJECT_ROOT = Path(__file__).resolve().parents[1] APP_DATA_DEFAULT = PROJECT_ROOT / "Data" CONFIG_JSON = APP_DATA_DEFAULT / "Settings" / "config.json" LEGACY_CONFIG_JSONS = ( APP_DATA_DEFAULT / "config.json", PROJECT_ROOT / "Scripts" / "config.json", ) LEGACY_CONFIG_JSON = LEGACY_CONFIG_JSONS[0] AUDIO_PROMPT_FILE_DEFAULTS: Dict[str, str] = { "welcome_single": "welcome_single.wav", "welcome_group": "welcome_group.wav", "welcome_returning": "welcome_returning.wav", "frame_single": "frame_single.wav", "frame_group": "frame_group.wav", "confirm_reminder": "confirm_reminder.wav", "visitor_left": "visitor_left.wav", "declined": "declined.wav", "confirm_timeout": "confirm_timeout.wav", "session_cancelled": "session_cancelled.wav", "framing_timeout": "framing_timeout.wav", "countdown_intro": "countdown_intro.wav", "count_3": "count_3.wav", "count_2": "count_2.wav", "count_1": "count_1.wav", "smile": "smile.wav", "countdown_cancelled": "countdown_cancelled.wav", "lost_from_frame": "lost_from_frame.wav", "retake_recommended": "retake_recommended.wav", "retake_yes": "retake_yes.wav", "retake_limit": "retake_limit.wav", "photo_saved_thanks": "photo_saved_thanks.wav", } _FALLBACK: Dict[str, Any] = { "paths": { "data_dir": "AI_Photographer/Data/G1", "app_data_dir": "AI_Photographer/Data", "app_settings_dir": "AI_Photographer/Data/Settings", "app_scripts_dir": "AI_Photographer/Data/Scripts", "app_runtime_dir": "AI_Photographer/Data/Runtime", "app_notes_dir": "AI_Photographer/Data/Notes", "audio_prompts_dir": "AI_Photographer/Data/Audio", "audio_prompt_records_file": "AI_Photographer/Data/Settings/audio_prompt_records.json", "scripts_dir": "AI_Photographer/Scripts", "web_dir": "AI_Photographer/Web", "photos_dir": "AI_Photographer/photos/Captures", "people_dir": "AI_Photographer/photos/people", "samples_dir": "AI_Photographer/photos/samples", "replay_recordings_dir": "AI_Photographer/Data/G1", "replay_recorder_script": "", "home_file": "arm_home.jsonl", "photo_phrases_file": "AI_Photographer/Data/Scripts/photo_command_ai.txt", "sanad_script_file": "AI_Photographer/Data/Scripts/sanad_script.txt", "runtime_health_file": "AI_Photographer/Data/Runtime/runtime_health.json", "autonomous_state_file": "AI_Photographer/Data/Runtime/autonomous_state.json", "upload_db": "AI_Photographer/Data/Runtime/upload_db.json", }, "timing": { "photo_total_sec": 10.0, "photo_thanks_sec": 3.0, "photo_delay_sec": 5.0, "replay_capture_end_margin_sec": 0.25, "loop_rate": 10.0, "ai_query_interval": 1.0, }, "server": {"photo_server_port": 8080}, "gemini": { "api_key": "", "mic_enabled": True, "model": "models/gemini-2.5-flash-native-audio-preview-12-2025", "voice_name": "Charon", "system_prompt_fallback": ( "You are Sanad (Bousandah), a friendly Emirati photographer assistant. " "Speak in UAE dialect (Khaleeji). Be short, energetic, and helpful." ), }, "upload": { "method": "http", "url": "", "s3_bucket": "", "s3_region": "", "s3_key": "", "s3_secret": "", }, "mode": { "default_mode": "manual", "current_mode": "manual", }, "replay": { "active_file": "photo_G3.jsonl", }, "camera": { "camera_index": 0, "frame_width": 640, "frame_height": 480, "fps": 30, "preferred_realsense_serial": "243622071722", }, "vision": { "detection_backend": "yolo", "yolo_runtime": "ultralytics", "yolo_ultralytics_device": "cpu", "person_yolo_onnx": "", "face_yolo_onnx": "", "input_size": 640, "person_class_id": 0, "person_score_thresh": 0.35, "face_score_thresh": 0.35, "nms_iou_thresh": 0.45, "group_min_people": 3, "group_link_distance_px": 220.0, "yolo_strict_required": True, "gemini_context_hz": 8.0, "gemini_context_silent": True, "idle_voice_listen_enabled": True, "hard_target_lock_enabled": True, "retake_prompt_enabled": True, "autonomous_greeting_replay_enabled": True, "autonomous_greeting_replay_file": "right_hand_up.jsonl", "autonomous_capture_replay_enabled": True, "retake_max_per_session": 1, "framing_headroom_min_ratio": 0.06, "framing_headroom_max_ratio": 0.25, "framing_eye_line_min_ratio": 0.28, "framing_eye_line_max_ratio": 0.48, "framing_retake_score_threshold": 0.68, "face_recognition_enabled": True, "face_recognition_threshold": 0.88, }, "watchdog": { "ws_initial_backoff_sec": 1.0, "ws_max_backoff_sec": 20.0, "component_restart_delay_sec": 1.0, "camera_capture_retry_count": 2, "camera_capture_retry_delay_sec": 0.8, }, "audio_prompts": { "mode": "audio", "fallback_to_gemini": True, "files": copy.deepcopy(AUDIO_PROMPT_FILE_DEFAULTS), }, } def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: out = dict(base) for k, v in override.items(): if isinstance(v, dict) and isinstance(out.get(k), dict): out[k] = _deep_merge(out[k], v) else: out[k] = v return out def _load_config() -> Dict[str, Any]: for cfg_path in (CONFIG_JSON, *LEGACY_CONFIG_JSONS): if not cfg_path.exists(): continue try: raw = json.loads(cfg_path.read_text(encoding="utf-8")) if isinstance(raw, dict): return _deep_merge(_FALLBACK, raw) except Exception: pass return _FALLBACK _CFG = _load_config() def _c(path: str, default: Any = None) -> Any: cur: Any = _CFG for part in path.split("."): if not isinstance(cur, dict) or part not in cur: return default cur = cur[part] return cur def _env(name: str, default: Any, cast): val = os.environ.get(name) if val is None or val == "": return default try: return cast(val) except Exception: return default def _as_path(rel_or_abs: str) -> Path: p = Path(str(rel_or_abs)).expanduser() if not p.is_absolute(): parts = p.parts if parts and parts[0] == PROJECT_ROOT.name: p = (PROJECT_ROOT.parent / p).resolve() else: p = (PROJECT_ROOT / p).resolve() return p # ================================================== # PATHS # ================================================== DATA_DIR = _as_path(_c("paths.data_dir", "Data/G1")) APP_DATA_DIR = _as_path(_c("paths.app_data_dir", "Data")) APP_SETTINGS_DIR = _as_path(_c("paths.app_settings_dir", "Data/Settings")) APP_SCRIPTS_DIR = _as_path(_c("paths.app_scripts_dir", "Data/Scripts")) APP_RUNTIME_DIR = _as_path(_c("paths.app_runtime_dir", "Data/Runtime")) APP_NOTES_DIR = _as_path(_c("paths.app_notes_dir", "Data/Notes")) AUDIO_PROMPTS_DIR = _as_path( _c("paths.audio_prompts_dir", "AI_Photographer/Data/Audio") ) AUDIO_PROMPT_RECORDS_FILE = _as_path( _c( "paths.audio_prompt_records_file", "AI_Photographer/Data/Settings/audio_prompt_records.json", ) ) SCRIPTS_DIR = _as_path(_c("paths.scripts_dir", "Scripts")) WEB_DIR = _as_path(_c("paths.web_dir", "Web")) PHOTOS_DIR = _as_path(_c("paths.photos_dir", "photos/Captures")) PEOPLE_DIR = _as_path(_c("paths.people_dir", "photos/people")) SAMPLES_DIR = _as_path(_c("paths.samples_dir", "photos/samples")) REPLAY_FILE = (DATA_DIR / str(_c("replay.active_file", "photo_G3.jsonl"))).resolve() REPLAY_RECORDINGS_DIR = _as_path(_c("paths.replay_recordings_dir", "Data/G1")) _REPLAY_RECORDER_SCRIPT_RAW = str(_c("paths.replay_recorder_script", "") or "").strip() REPLAY_RECORDER_SCRIPT = _as_path(_REPLAY_RECORDER_SCRIPT_RAW) if _REPLAY_RECORDER_SCRIPT_RAW else "" HOME_FILE = (DATA_DIR / str(_c("paths.home_file", "arm_home.jsonl"))).resolve() PHOTO_PHRASES_FILE = _as_path(_c("paths.photo_phrases_file", "Data/Scripts/photo_command_ai.txt")) SANAD_SCRIPT_FILE = _as_path(_c("paths.sanad_script_file", "Data/Scripts/sanad_script.txt")) RUNTIME_HEALTH_FILE = _as_path(_c("paths.runtime_health_file", "Data/Runtime/runtime_health.json")) AUTONOMOUS_STATE_FILE = _as_path(_c("paths.autonomous_state_file", "Data/Runtime/autonomous_state.json")) UPLOAD_DB = _as_path(_c("paths.upload_db", "Data/Runtime/upload_db.json")) # ================================================== # TIMING / PHOTOGRAPHER # ================================================== PHOTO_TOTAL_SEC = _env("PHOTO_TOTAL_SEC", float(_c("timing.photo_total_sec", 10.0)), float) PHOTO_THANKS_SEC = _env("PHOTO_THANKS_SEC", float(_c("timing.photo_thanks_sec", 3.0)), float) PHOTO_DELAY_SEC = _env("PHOTO_DELAY_SEC", float(_c("timing.photo_delay_sec", 5.0)), float) REPLAY_CAPTURE_END_MARGIN_SEC = _env( "REPLAY_CAPTURE_END_MARGIN_SEC", float(_c("timing.replay_capture_end_margin_sec", 0.25)), float, ) LOOP_RATE = _env("LOOP_RATE", float(_c("timing.loop_rate", 10.0)), float) AI_QUERY_INTERVAL = _env("AI_QUERY_INTERVAL", float(_c("timing.ai_query_interval", 1.0)), float) # ================================================== # PHOTO SERVER # ================================================== PHOTO_SERVER_PORT = _env("PHOTO_SERVER_PORT", int(_c("server.photo_server_port", 8080)), int) # ================================================== # GEMINI # ================================================== # Gemini key source: Data/Settings/config.json -> gemini.api_key GEMINI_API_KEY = str(_c("gemini.api_key", "")).strip() GEMINI_MODEL = _env("GEMINI_MODEL", str(_c("gemini.model", "models/gemini-2.5-flash-native-audio-preview-12-2025")).strip(), str).strip() VOICE_NAME = _env("VOICE_NAME", str(_c("gemini.voice_name", "Charon")).strip() or "Charon", str).strip() or "Charon" SYSTEM_PROMPT_FALLBACK = str(_c("gemini.system_prompt_fallback", _FALLBACK["gemini"]["system_prompt_fallback"])) # Keep alias for compatibility MODEL = GEMINI_MODEL URI = ( "wss://generativelanguage.googleapis.com/ws/" "google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent" f"?key={GEMINI_API_KEY}" ) def validate_api_key(key: str) -> bool: k = (key or "").strip() if not k: return False if "your_" in k.lower() or "replace" in k.lower(): return False return len(k) >= 20 def load_system_prompt() -> str: try: if SANAD_SCRIPT_FILE.exists(): content = SANAD_SCRIPT_FILE.read_text(encoding="utf-8-sig").strip() if content: return content except Exception: pass return SYSTEM_PROMPT_FALLBACK def read_gemini_mic_enabled() -> bool: raw = _read_config_json_raw() gemini_cfg = raw.get("gemini") if not isinstance(gemini_cfg, dict): gemini_cfg = {} return _coerce_bool(gemini_cfg.get("mic_enabled", _FALLBACK["gemini"]["mic_enabled"]), True) def write_gemini_mic_enabled(enabled: Any) -> bool: raw = _read_config_json_raw() gemini_cfg = raw.get("gemini") if not isinstance(gemini_cfg, dict): gemini_cfg = {} gemini_cfg["mic_enabled"] = _coerce_bool(enabled, bool(_FALLBACK["gemini"]["mic_enabled"])) raw["gemini"] = gemini_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return bool(gemini_cfg["mic_enabled"]) def read_camera_preferred_realsense_serial() -> str: raw = _read_config_json_raw() camera_cfg = raw.get("camera") if not isinstance(camera_cfg, dict): camera_cfg = {} return str(camera_cfg.get("preferred_realsense_serial", _FALLBACK["camera"]["preferred_realsense_serial"]) or "").strip() def write_camera_preferred_realsense_serial(serial: Any) -> str: raw = _read_config_json_raw() camera_cfg = raw.get("camera") if not isinstance(camera_cfg, dict): camera_cfg = {} camera_cfg["preferred_realsense_serial"] = str(serial or "").strip() raw["camera"] = camera_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return str(camera_cfg["preferred_realsense_serial"]) def resolve_replay_path(path_value: Any) -> Path: p = Path(str(path_value or "").strip()).expanduser() if not p.is_absolute(): p = (DATA_DIR / p).resolve() return p def read_selected_replay_name() -> str: raw = _read_config_json_raw() raw = _ensure_replay_block(raw) replay_cfg = raw.get("replay", {}) value = str(replay_cfg.get("active_file", "") or "").strip() if value: return value.replace("\\", "/").lstrip("/") try: return str(REPLAY_FILE.resolve().relative_to(DATA_DIR)).replace("\\", "/") except Exception: return REPLAY_FILE.name def read_selected_replay_path() -> Path: return resolve_replay_path(read_selected_replay_name()) def write_selected_replay_name(name: Any) -> str: global REPLAY_FILE clean = str(name or "").strip().replace("\\", "/").lstrip("/") raw = _read_config_json_raw() raw = _ensure_replay_block(raw) replay_cfg = raw.get("replay", {}) replay_cfg["active_file"] = clean raw["replay"] = replay_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) try: REPLAY_FILE = resolve_replay_path(clean) except Exception: pass return clean # ================================================== # UPLOAD / CLOUD # ================================================== UPLOAD_METHOD = _env("UPLOAD_METHOD", str(_c("upload.method", "http")).strip(), str).strip() UPLOAD_URL = _env("UPLOAD_URL", str(_c("upload.url", "")).strip(), str).strip() UPLOAD_S3_BUCKET = _env("UPLOAD_S3_BUCKET", str(_c("upload.s3_bucket", "")).strip(), str).strip() UPLOAD_S3_REGION = _env("UPLOAD_S3_REGION", str(_c("upload.s3_region", "")).strip(), str).strip() UPLOAD_S3_KEY = _env("UPLOAD_S3_KEY", str(_c("upload.s3_key", "")).strip(), str).strip() UPLOAD_S3_SECRET = _env("UPLOAD_S3_SECRET", str(_c("upload.s3_secret", "")).strip(), str).strip() # ================================================== # MODE / CONTROL # ================================================== DEFAULT_MODE = _env("DEFAULT_MODE", str(_c("mode.default_mode", "manual")).strip(), str).strip() or "manual" if DEFAULT_MODE == "command": DEFAULT_MODE = "ai" if DEFAULT_MODE not in ("manual", "ai"): DEFAULT_MODE = "manual" try: UPLOAD_DB.parent.mkdir(parents=True, exist_ok=True) except Exception: pass for _dir in ( APP_DATA_DIR, APP_SETTINGS_DIR, APP_SCRIPTS_DIR, APP_RUNTIME_DIR, APP_NOTES_DIR, AUDIO_PROMPTS_DIR, AUDIO_PROMPT_RECORDS_FILE.parent, PHOTOS_DIR, PEOPLE_DIR, SAMPLES_DIR, REPLAY_RECORDINGS_DIR, ): try: _dir.mkdir(parents=True, exist_ok=True) except Exception: pass def _read_config_json_raw() -> Dict[str, Any]: for cfg_path in (CONFIG_JSON, *LEGACY_CONFIG_JSONS): try: if cfg_path.exists(): raw = json.loads(cfg_path.read_text(encoding="utf-8")) if isinstance(raw, dict): return raw except Exception: pass return {} def _write_config_json_raw(raw: Dict[str, Any]) -> None: CONFIG_JSON.parent.mkdir(parents=True, exist_ok=True) CONFIG_JSON.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") def _coerce_mode(value: Any) -> str: m = str(value or "").strip().lower() if m == "command": return "ai" if m not in ("manual", "ai"): return "manual" return m def _coerce_bool(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): return value.strip().lower() in ("1", "true", "yes", "on", "y") return bool(default) def _coerce_detection_backend(value: Any) -> str: v = str(value or "").strip().lower() if v in ("yolo", "normal"): return v return "yolo" def _coerce_audio_prompt_mode(value: Any) -> str: v = str(value or "").strip().lower() if v in ("audio", "gemini"): return v return "audio" def _coerce_yolo_runtime(value: Any) -> str: v = str(value or "").strip().lower() if v in ("ultralytics", "opencv"): return v return "ultralytics" def _coerce_int(value: Any, default: int, min_v: int | None = None, max_v: int | None = None) -> int: try: out = int(value) except Exception: out = int(default) if min_v is not None: out = max(min_v, out) if max_v is not None: out = min(max_v, out) return out def _coerce_float(value: Any, default: float, min_v: float | None = None, max_v: float | None = None) -> float: try: out = float(value) except Exception: out = float(default) if min_v is not None: out = max(min_v, out) if max_v is not None: out = min(max_v, out) return out def _ensure_mode_block(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(raw, dict) or not raw: raw = copy.deepcopy(_FALLBACK) mode_cfg = raw.get("mode") if not isinstance(mode_cfg, dict): mode_cfg = {} default_mode = _coerce_mode(mode_cfg.get("default_mode", DEFAULT_MODE)) current_mode = _coerce_mode(mode_cfg.get("current_mode", default_mode)) mode_cfg["default_mode"] = default_mode mode_cfg["current_mode"] = current_mode raw["mode"] = mode_cfg return raw def _ensure_replay_block(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(raw, dict) or not raw: raw = copy.deepcopy(_FALLBACK) replay_cfg = raw.get("replay") if not isinstance(replay_cfg, dict): replay_cfg = {} active_file = str(replay_cfg.get("active_file", _FALLBACK["replay"]["active_file"]) or "").strip() replay_cfg["active_file"] = active_file or str(_FALLBACK["replay"]["active_file"]) raw["replay"] = replay_cfg return raw def _ensure_vision_block(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(raw, dict) or not raw: raw = copy.deepcopy(_FALLBACK) vision_cfg = raw.get("vision") if not isinstance(vision_cfg, dict): vision_cfg = {} vision_cfg["detection_backend"] = _coerce_detection_backend( vision_cfg.get("detection_backend", _FALLBACK["vision"]["detection_backend"]) ) vision_cfg["yolo_runtime"] = _coerce_yolo_runtime( vision_cfg.get("yolo_runtime", _FALLBACK["vision"]["yolo_runtime"]) ) dev = str(vision_cfg.get("yolo_ultralytics_device", _FALLBACK["vision"]["yolo_ultralytics_device"]) or "").strip() vision_cfg["yolo_ultralytics_device"] = dev or "cpu" try: group_min = int(vision_cfg.get("group_min_people", _FALLBACK["vision"]["group_min_people"])) except Exception: group_min = int(_FALLBACK["vision"]["group_min_people"]) vision_cfg["group_min_people"] = max(2, group_min) vision_cfg["yolo_strict_required"] = _coerce_bool( vision_cfg.get("yolo_strict_required", _FALLBACK["vision"]["yolo_strict_required"]), bool(_FALLBACK["vision"]["yolo_strict_required"]), ) try: hz = float(vision_cfg.get("gemini_context_hz", _FALLBACK["vision"]["gemini_context_hz"])) except Exception: hz = float(_FALLBACK["vision"]["gemini_context_hz"]) vision_cfg["gemini_context_hz"] = max(0.5, min(30.0, hz)) vision_cfg["gemini_context_silent"] = _coerce_bool( vision_cfg.get("gemini_context_silent", _FALLBACK["vision"]["gemini_context_silent"]), bool(_FALLBACK["vision"]["gemini_context_silent"]), ) vision_cfg["idle_voice_listen_enabled"] = _coerce_bool( vision_cfg.get("idle_voice_listen_enabled", _FALLBACK["vision"]["idle_voice_listen_enabled"]), bool(_FALLBACK["vision"]["idle_voice_listen_enabled"]), ) vision_cfg["hard_target_lock_enabled"] = _coerce_bool( vision_cfg.get("hard_target_lock_enabled", _FALLBACK["vision"]["hard_target_lock_enabled"]), bool(_FALLBACK["vision"]["hard_target_lock_enabled"]), ) vision_cfg["retake_prompt_enabled"] = _coerce_bool( vision_cfg.get("retake_prompt_enabled", _FALLBACK["vision"]["retake_prompt_enabled"]), bool(_FALLBACK["vision"]["retake_prompt_enabled"]), ) vision_cfg["autonomous_greeting_replay_enabled"] = _coerce_bool( vision_cfg.get("autonomous_greeting_replay_enabled", _FALLBACK["vision"]["autonomous_greeting_replay_enabled"]), bool(_FALLBACK["vision"]["autonomous_greeting_replay_enabled"]), ) greet_replay_file = str( vision_cfg.get("autonomous_greeting_replay_file", _FALLBACK["vision"]["autonomous_greeting_replay_file"]) or "" ).strip() vision_cfg["autonomous_greeting_replay_file"] = ( greet_replay_file or str(_FALLBACK["vision"]["autonomous_greeting_replay_file"]) ) vision_cfg["autonomous_capture_replay_enabled"] = _coerce_bool( vision_cfg.get("autonomous_capture_replay_enabled", _FALLBACK["vision"]["autonomous_capture_replay_enabled"]), bool(_FALLBACK["vision"]["autonomous_capture_replay_enabled"]), ) vision_cfg["retake_max_per_session"] = _coerce_int( vision_cfg.get("retake_max_per_session", _FALLBACK["vision"]["retake_max_per_session"]), int(_FALLBACK["vision"]["retake_max_per_session"]), min_v=0, max_v=5, ) vision_cfg["framing_headroom_min_ratio"] = _coerce_float( vision_cfg.get("framing_headroom_min_ratio", _FALLBACK["vision"]["framing_headroom_min_ratio"]), float(_FALLBACK["vision"]["framing_headroom_min_ratio"]), min_v=0.0, max_v=0.8, ) vision_cfg["framing_headroom_max_ratio"] = _coerce_float( vision_cfg.get("framing_headroom_max_ratio", _FALLBACK["vision"]["framing_headroom_max_ratio"]), float(_FALLBACK["vision"]["framing_headroom_max_ratio"]), min_v=0.0, max_v=0.95, ) if vision_cfg["framing_headroom_max_ratio"] <= vision_cfg["framing_headroom_min_ratio"]: vision_cfg["framing_headroom_max_ratio"] = min(0.95, vision_cfg["framing_headroom_min_ratio"] + 0.05) vision_cfg["framing_eye_line_min_ratio"] = _coerce_float( vision_cfg.get("framing_eye_line_min_ratio", _FALLBACK["vision"]["framing_eye_line_min_ratio"]), float(_FALLBACK["vision"]["framing_eye_line_min_ratio"]), min_v=0.0, max_v=0.9, ) vision_cfg["framing_eye_line_max_ratio"] = _coerce_float( vision_cfg.get("framing_eye_line_max_ratio", _FALLBACK["vision"]["framing_eye_line_max_ratio"]), float(_FALLBACK["vision"]["framing_eye_line_max_ratio"]), min_v=0.05, max_v=1.0, ) if vision_cfg["framing_eye_line_max_ratio"] <= vision_cfg["framing_eye_line_min_ratio"]: vision_cfg["framing_eye_line_max_ratio"] = min(1.0, vision_cfg["framing_eye_line_min_ratio"] + 0.05) vision_cfg["framing_retake_score_threshold"] = _coerce_float( vision_cfg.get("framing_retake_score_threshold", _FALLBACK["vision"]["framing_retake_score_threshold"]), float(_FALLBACK["vision"]["framing_retake_score_threshold"]), min_v=0.0, max_v=1.0, ) vision_cfg["face_recognition_enabled"] = _coerce_bool( vision_cfg.get("face_recognition_enabled", _FALLBACK["vision"]["face_recognition_enabled"]), bool(_FALLBACK["vision"]["face_recognition_enabled"]), ) vision_cfg["face_recognition_threshold"] = _coerce_float( vision_cfg.get("face_recognition_threshold", _FALLBACK["vision"]["face_recognition_threshold"]), float(_FALLBACK["vision"]["face_recognition_threshold"]), min_v=0.5, max_v=0.995, ) raw["vision"] = vision_cfg return raw def _ensure_watchdog_block(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(raw, dict) or not raw: raw = copy.deepcopy(_FALLBACK) wd_cfg = raw.get("watchdog") if not isinstance(wd_cfg, dict): wd_cfg = {} wd_cfg["ws_initial_backoff_sec"] = _coerce_float( wd_cfg.get("ws_initial_backoff_sec", _FALLBACK["watchdog"]["ws_initial_backoff_sec"]), float(_FALLBACK["watchdog"]["ws_initial_backoff_sec"]), min_v=0.1, max_v=60.0, ) wd_cfg["ws_max_backoff_sec"] = _coerce_float( wd_cfg.get("ws_max_backoff_sec", _FALLBACK["watchdog"]["ws_max_backoff_sec"]), float(_FALLBACK["watchdog"]["ws_max_backoff_sec"]), min_v=0.5, max_v=300.0, ) if wd_cfg["ws_max_backoff_sec"] < wd_cfg["ws_initial_backoff_sec"]: wd_cfg["ws_max_backoff_sec"] = wd_cfg["ws_initial_backoff_sec"] wd_cfg["component_restart_delay_sec"] = _coerce_float( wd_cfg.get("component_restart_delay_sec", _FALLBACK["watchdog"]["component_restart_delay_sec"]), float(_FALLBACK["watchdog"]["component_restart_delay_sec"]), min_v=0.1, max_v=20.0, ) wd_cfg["camera_capture_retry_count"] = _coerce_int( wd_cfg.get("camera_capture_retry_count", _FALLBACK["watchdog"]["camera_capture_retry_count"]), int(_FALLBACK["watchdog"]["camera_capture_retry_count"]), min_v=0, max_v=10, ) wd_cfg["camera_capture_retry_delay_sec"] = _coerce_float( wd_cfg.get("camera_capture_retry_delay_sec", _FALLBACK["watchdog"]["camera_capture_retry_delay_sec"]), float(_FALLBACK["watchdog"]["camera_capture_retry_delay_sec"]), min_v=0.0, max_v=30.0, ) raw["watchdog"] = wd_cfg return raw def _ensure_audio_prompts_block(raw: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(raw, dict) or not raw: raw = copy.deepcopy(_FALLBACK) prompt_cfg = raw.get("audio_prompts") if not isinstance(prompt_cfg, dict): prompt_cfg = {} prompt_cfg["mode"] = _coerce_audio_prompt_mode( prompt_cfg.get("mode", _FALLBACK["audio_prompts"]["mode"]) ) prompt_cfg["fallback_to_gemini"] = _coerce_bool( prompt_cfg.get("fallback_to_gemini", _FALLBACK["audio_prompts"]["fallback_to_gemini"]), bool(_FALLBACK["audio_prompts"]["fallback_to_gemini"]), ) files_cfg = prompt_cfg.get("files") if not isinstance(files_cfg, dict): files_cfg = {} normalized_files: Dict[str, str] = {} for key, default_name in AUDIO_PROMPT_FILE_DEFAULTS.items(): clean = str(files_cfg.get(key, default_name) or "").strip().replace("\\", "/").lstrip("/") normalized_files[key] = clean or default_name prompt_cfg["files"] = normalized_files raw["audio_prompts"] = prompt_cfg return raw def _refresh_cached_cfg(raw: Dict[str, Any]) -> None: global _CFG _CFG = _deep_merge(_FALLBACK, raw if isinstance(raw, dict) else {}) def read_runtime_mode() -> str: raw = _read_config_json_raw() raw = _ensure_mode_block(raw) mode_cfg = raw.get("mode", {}) return _coerce_mode(mode_cfg.get("current_mode", mode_cfg.get("default_mode", DEFAULT_MODE))) def write_runtime_mode(mode: str) -> str: raw = _read_config_json_raw() raw = _ensure_mode_block(raw) mode_cfg = raw.get("mode", {}) mode_cfg["current_mode"] = _coerce_mode(mode) raw["mode"] = mode_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return mode_cfg["current_mode"] def read_vision_detector_backend() -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) return _coerce_detection_backend(vision_cfg.get("detection_backend", "yolo")) def write_vision_detector_backend(backend: str) -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) vision_cfg["detection_backend"] = _coerce_detection_backend(backend) raw["vision"] = vision_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return str(vision_cfg["detection_backend"]) def read_vision_yolo_runtime() -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) return _coerce_yolo_runtime(vision_cfg.get("yolo_runtime", _FALLBACK["vision"]["yolo_runtime"])) def read_vision_yolo_ultralytics_device() -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) dev = str(vision_cfg.get("yolo_ultralytics_device", _FALLBACK["vision"]["yolo_ultralytics_device"]) or "").strip() return dev or "cpu" def read_vision_yolo_strict_required() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) return _coerce_bool(vision_cfg.get("yolo_strict_required", _FALLBACK["vision"]["yolo_strict_required"]), True) def read_vision_gemini_context_hz() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) try: hz = float(vision_cfg.get("gemini_context_hz", _FALLBACK["vision"]["gemini_context_hz"])) except Exception: hz = float(_FALLBACK["vision"]["gemini_context_hz"]) return max(0.5, min(30.0, hz)) def read_vision_gemini_context_silent() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) return _coerce_bool(vision_cfg.get("gemini_context_silent", _FALLBACK["vision"]["gemini_context_silent"]), True) def read_vision_idle_voice_listen_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) return _coerce_bool( vision_cfg.get("idle_voice_listen_enabled", _FALLBACK["vision"]["idle_voice_listen_enabled"]), True, ) def _write_vision_cfg_value(key: str, value: Any, *, bool_field: bool = False) -> Any: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) raw = _ensure_watchdog_block(raw) vision_cfg = raw.get("vision", {}) if bool_field: vision_cfg[key] = _coerce_bool(value, bool(_FALLBACK["vision"].get(key, False))) else: vision_cfg[key] = value raw["vision"] = vision_cfg raw = _ensure_vision_block(raw) _write_config_json_raw(raw) _refresh_cached_cfg(raw) return raw.get("vision", {}).get(key) def read_vision_hard_target_lock_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) return _coerce_bool(raw.get("vision", {}).get("hard_target_lock_enabled", True), True) def write_vision_hard_target_lock_enabled(enabled: Any) -> bool: return bool(_write_vision_cfg_value("hard_target_lock_enabled", enabled, bool_field=True)) def read_vision_retake_prompt_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) return _coerce_bool(raw.get("vision", {}).get("retake_prompt_enabled", True), True) def write_vision_retake_prompt_enabled(enabled: Any) -> bool: return bool(_write_vision_cfg_value("retake_prompt_enabled", enabled, bool_field=True)) def read_vision_autonomous_greeting_replay_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) return _coerce_bool( raw.get("vision", {}).get( "autonomous_greeting_replay_enabled", _FALLBACK["vision"]["autonomous_greeting_replay_enabled"], ), bool(_FALLBACK["vision"]["autonomous_greeting_replay_enabled"]), ) def read_vision_autonomous_greeting_replay_file() -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) val = str( raw.get("vision", {}).get( "autonomous_greeting_replay_file", _FALLBACK["vision"]["autonomous_greeting_replay_file"], ) or "" ).strip() return val or str(_FALLBACK["vision"]["autonomous_greeting_replay_file"]) def read_vision_autonomous_capture_replay_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) return _coerce_bool( raw.get("vision", {}).get( "autonomous_capture_replay_enabled", _FALLBACK["vision"]["autonomous_capture_replay_enabled"], ), bool(_FALLBACK["vision"]["autonomous_capture_replay_enabled"]), ) def write_vision_autonomous_greeting_replay_enabled(enabled: Any) -> bool: return bool(_write_vision_cfg_value("autonomous_greeting_replay_enabled", enabled, bool_field=True)) def write_vision_autonomous_greeting_replay_file(filename: Any) -> str: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) cleaned = str(filename or "").strip().replace("\\", "/").lstrip("/") vision_cfg["autonomous_greeting_replay_file"] = cleaned or str( _FALLBACK["vision"]["autonomous_greeting_replay_file"] ) raw["vision"] = vision_cfg raw = _ensure_vision_block(raw) _write_config_json_raw(raw) _refresh_cached_cfg(raw) return str(raw.get("vision", {}).get("autonomous_greeting_replay_file", "")) def write_vision_autonomous_capture_replay_enabled(enabled: Any) -> bool: return bool(_write_vision_cfg_value("autonomous_capture_replay_enabled", enabled, bool_field=True)) def read_vision_retake_max_per_session() -> int: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("retake_max_per_session", _FALLBACK["vision"]["retake_max_per_session"]) return _coerce_int(v, int(_FALLBACK["vision"]["retake_max_per_session"]), min_v=0, max_v=5) def read_vision_framing_headroom_min_ratio() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("framing_headroom_min_ratio", _FALLBACK["vision"]["framing_headroom_min_ratio"]) return _coerce_float(v, float(_FALLBACK["vision"]["framing_headroom_min_ratio"]), min_v=0.0, max_v=0.8) def read_vision_framing_headroom_max_ratio() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("framing_headroom_max_ratio", _FALLBACK["vision"]["framing_headroom_max_ratio"]) return _coerce_float(v, float(_FALLBACK["vision"]["framing_headroom_max_ratio"]), min_v=0.0, max_v=0.95) def read_vision_framing_eye_line_min_ratio() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("framing_eye_line_min_ratio", _FALLBACK["vision"]["framing_eye_line_min_ratio"]) return _coerce_float(v, float(_FALLBACK["vision"]["framing_eye_line_min_ratio"]), min_v=0.0, max_v=0.9) def read_vision_framing_eye_line_max_ratio() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("framing_eye_line_max_ratio", _FALLBACK["vision"]["framing_eye_line_max_ratio"]) return _coerce_float(v, float(_FALLBACK["vision"]["framing_eye_line_max_ratio"]), min_v=0.05, max_v=1.0) def read_vision_framing_retake_score_threshold() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get( "framing_retake_score_threshold", _FALLBACK["vision"]["framing_retake_score_threshold"], ) return _coerce_float(v, float(_FALLBACK["vision"]["framing_retake_score_threshold"]), min_v=0.0, max_v=1.0) def read_vision_face_recognition_enabled() -> bool: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) return _coerce_bool( raw.get("vision", {}).get("face_recognition_enabled", _FALLBACK["vision"]["face_recognition_enabled"]), bool(_FALLBACK["vision"]["face_recognition_enabled"]), ) def write_vision_face_recognition_enabled(enabled: Any) -> bool: return bool(_write_vision_cfg_value("face_recognition_enabled", enabled, bool_field=True)) def read_vision_face_recognition_threshold() -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) v = raw.get("vision", {}).get("face_recognition_threshold", _FALLBACK["vision"]["face_recognition_threshold"]) return _coerce_float(v, float(_FALLBACK["vision"]["face_recognition_threshold"]), min_v=0.5, max_v=0.995) def write_vision_face_recognition_threshold(value: Any) -> float: raw = _read_config_json_raw() raw = _ensure_vision_block(raw) vision_cfg = raw.get("vision", {}) vision_cfg["face_recognition_threshold"] = _coerce_float( value, float(_FALLBACK["vision"]["face_recognition_threshold"]), min_v=0.5, max_v=0.995, ) raw["vision"] = vision_cfg raw = _ensure_vision_block(raw) _write_config_json_raw(raw) _refresh_cached_cfg(raw) return float(raw.get("vision", {}).get("face_recognition_threshold", _FALLBACK["vision"]["face_recognition_threshold"])) def read_watchdog_ws_initial_backoff_sec() -> float: raw = _read_config_json_raw() raw = _ensure_watchdog_block(raw) wd = raw.get("watchdog", {}) return _coerce_float( wd.get("ws_initial_backoff_sec", _FALLBACK["watchdog"]["ws_initial_backoff_sec"]), float(_FALLBACK["watchdog"]["ws_initial_backoff_sec"]), min_v=0.1, max_v=60.0, ) def read_watchdog_ws_max_backoff_sec() -> float: raw = _read_config_json_raw() raw = _ensure_watchdog_block(raw) wd = raw.get("watchdog", {}) return _coerce_float( wd.get("ws_max_backoff_sec", _FALLBACK["watchdog"]["ws_max_backoff_sec"]), float(_FALLBACK["watchdog"]["ws_max_backoff_sec"]), min_v=0.5, max_v=300.0, ) def read_watchdog_component_restart_delay_sec() -> float: raw = _read_config_json_raw() raw = _ensure_watchdog_block(raw) wd = raw.get("watchdog", {}) return _coerce_float( wd.get("component_restart_delay_sec", _FALLBACK["watchdog"]["component_restart_delay_sec"]), float(_FALLBACK["watchdog"]["component_restart_delay_sec"]), min_v=0.1, max_v=20.0, ) def read_watchdog_camera_capture_retry_count() -> int: raw = _read_config_json_raw() raw = _ensure_watchdog_block(raw) wd = raw.get("watchdog", {}) return _coerce_int( wd.get("camera_capture_retry_count", _FALLBACK["watchdog"]["camera_capture_retry_count"]), int(_FALLBACK["watchdog"]["camera_capture_retry_count"]), min_v=0, max_v=10, ) def read_watchdog_camera_capture_retry_delay_sec() -> float: raw = _read_config_json_raw() raw = _ensure_watchdog_block(raw) wd = raw.get("watchdog", {}) return _coerce_float( wd.get("camera_capture_retry_delay_sec", _FALLBACK["watchdog"]["camera_capture_retry_delay_sec"]), float(_FALLBACK["watchdog"]["camera_capture_retry_delay_sec"]), min_v=0.0, max_v=30.0, ) def read_audio_prompts_fallback_to_gemini() -> bool: raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) return _coerce_bool(prompt_cfg.get("fallback_to_gemini", True), True) def read_audio_prompt_mode() -> str: raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) return _coerce_audio_prompt_mode(prompt_cfg.get("mode", "audio")) def write_audio_prompt_mode(mode: Any) -> str: raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) prompt_cfg["mode"] = _coerce_audio_prompt_mode(mode) raw["audio_prompts"] = prompt_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return str(prompt_cfg["mode"]) def write_audio_prompts_fallback_to_gemini(enabled: Any) -> bool: raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) prompt_cfg["fallback_to_gemini"] = _coerce_bool(enabled, True) raw["audio_prompts"] = prompt_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return bool(prompt_cfg["fallback_to_gemini"]) def read_audio_prompt_files() -> Dict[str, str]: raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) files_cfg = prompt_cfg.get("files", {}) out: Dict[str, str] = {} for key, default_name in AUDIO_PROMPT_FILE_DEFAULTS.items(): clean = str(files_cfg.get(key, default_name) or "").strip().replace("\\", "/").lstrip("/") out[key] = clean or default_name return out def read_audio_prompt_filename(key: str) -> str: key = str(key or "").strip() if key not in AUDIO_PROMPT_FILE_DEFAULTS: raise KeyError(f"unknown audio prompt key: {key}") return str(read_audio_prompt_files().get(key, AUDIO_PROMPT_FILE_DEFAULTS[key])) def write_audio_prompt_filename(key: str, filename: Any) -> str: key = str(key or "").strip() if key not in AUDIO_PROMPT_FILE_DEFAULTS: raise KeyError(f"unknown audio prompt key: {key}") raw = _read_config_json_raw() raw = _ensure_audio_prompts_block(raw) prompt_cfg = raw.get("audio_prompts", {}) files_cfg = prompt_cfg.get("files", {}) clean = str(filename or "").strip().replace("\\", "/").lstrip("/") files_cfg[key] = clean or AUDIO_PROMPT_FILE_DEFAULTS[key] prompt_cfg["files"] = files_cfg raw["audio_prompts"] = prompt_cfg _write_config_json_raw(raw) _refresh_cached_cfg(raw) return str(files_cfg[key]) # Ensure runtime mode keys exist in config.json. try: _raw_cfg = _read_config_json_raw() _normalized = _ensure_mode_block(_raw_cfg) _normalized = _ensure_vision_block(_normalized) _normalized = _ensure_watchdog_block(_normalized) _normalized = _ensure_audio_prompts_block(_normalized) if _normalized != _raw_cfg or not CONFIG_JSON.exists(): _write_config_json_raw(_normalized) _refresh_cached_cfg(_normalized) except Exception: pass # CAMERA # ================================================== CAMERA_INDEX = _env("CAMERA_INDEX", int(_c("camera.camera_index", 0)), int) FRAME_WIDTH = _env("FRAME_WIDTH", int(_c("camera.frame_width", 640)), int) FRAME_HEIGHT = _env("FRAME_HEIGHT", int(_c("camera.frame_height", 480)), int) FPS = _env("FPS", int(_c("camera.fps", 30)), int) # ================================================== # VISION / DETECTOR # ================================================== VISION_DETECTION_BACKEND = _coerce_detection_backend(_c("vision.detection_backend", "yolo")) VISION_YOLO_RUNTIME = _coerce_yolo_runtime(_c("vision.yolo_runtime", "ultralytics")) VISION_YOLO_ULTRALYTICS_DEVICE = str(_c("vision.yolo_ultralytics_device", "cpu") or "").strip() or "cpu" VISION_PERSON_YOLO_ONNX = str(_c("vision.person_yolo_onnx", "")).strip() VISION_FACE_YOLO_ONNX = str(_c("vision.face_yolo_onnx", "")).strip() VISION_INPUT_SIZE = _env("DETECTOR_INPUT_SIZE", int(_c("vision.input_size", 640)), int) VISION_PERSON_CLASS_ID = _env("DETECTOR_PERSON_CLASS_ID", int(_c("vision.person_class_id", 0)), int) VISION_PERSON_SCORE_THRESH = _env( "DETECTOR_PERSON_SCORE_THRESH", float(_c("vision.person_score_thresh", 0.35)), float, ) VISION_FACE_SCORE_THRESH = _env( "DETECTOR_FACE_SCORE_THRESH", float(_c("vision.face_score_thresh", 0.35)), float, ) VISION_NMS_IOU_THRESH = _env("DETECTOR_NMS_IOU_THRESH", float(_c("vision.nms_iou_thresh", 0.45)), float) VISION_GROUP_MIN_PEOPLE = _env("DETECTOR_GROUP_MIN_PEOPLE", int(_c("vision.group_min_people", 3)), int) VISION_GROUP_LINK_DISTANCE_PX = _env( "DETECTOR_GROUP_LINK_DISTANCE_PX", float(_c("vision.group_link_distance_px", 220.0)), float, ) VISION_YOLO_STRICT_REQUIRED = _coerce_bool(_c("vision.yolo_strict_required", True), True) VISION_GEMINI_CONTEXT_HZ = max(0.5, float(_c("vision.gemini_context_hz", 8.0))) VISION_GEMINI_CONTEXT_SILENT = _coerce_bool(_c("vision.gemini_context_silent", True), True) VISION_HARD_TARGET_LOCK_ENABLED = _coerce_bool(_c("vision.hard_target_lock_enabled", True), True) VISION_RETAKE_PROMPT_ENABLED = _coerce_bool(_c("vision.retake_prompt_enabled", True), True) VISION_RETAKE_MAX_PER_SESSION = _coerce_int(_c("vision.retake_max_per_session", 1), 1, min_v=0, max_v=5) VISION_FRAMING_HEADROOM_MIN_RATIO = _coerce_float(_c("vision.framing_headroom_min_ratio", 0.06), 0.06, min_v=0.0, max_v=0.8) VISION_FRAMING_HEADROOM_MAX_RATIO = _coerce_float(_c("vision.framing_headroom_max_ratio", 0.25), 0.25, min_v=0.0, max_v=0.95) VISION_FRAMING_EYE_LINE_MIN_RATIO = _coerce_float(_c("vision.framing_eye_line_min_ratio", 0.28), 0.28, min_v=0.0, max_v=0.9) VISION_FRAMING_EYE_LINE_MAX_RATIO = _coerce_float(_c("vision.framing_eye_line_max_ratio", 0.48), 0.48, min_v=0.05, max_v=1.0) VISION_FRAMING_RETAKE_SCORE_THRESHOLD = _coerce_float(_c("vision.framing_retake_score_threshold", 0.68), 0.68, min_v=0.0, max_v=1.0) # ================================================== # WATCHDOG # ================================================== WATCHDOG_WS_INITIAL_BACKOFF_SEC = _coerce_float(_c("watchdog.ws_initial_backoff_sec", 1.0), 1.0, min_v=0.1, max_v=60.0) WATCHDOG_WS_MAX_BACKOFF_SEC = _coerce_float(_c("watchdog.ws_max_backoff_sec", 20.0), 20.0, min_v=0.5, max_v=300.0) WATCHDOG_COMPONENT_RESTART_DELAY_SEC = _coerce_float( _c("watchdog.component_restart_delay_sec", 1.0), 1.0, min_v=0.1, max_v=20.0, ) WATCHDOG_CAMERA_CAPTURE_RETRY_COUNT = _coerce_int(_c("watchdog.camera_capture_retry_count", 2), 2, min_v=0, max_v=10) WATCHDOG_CAMERA_CAPTURE_RETRY_DELAY_SEC = _coerce_float( _c("watchdog.camera_capture_retry_delay_sec", 0.8), 0.8, min_v=0.0, max_v=30.0, )