from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path import yaml @dataclass(slots=True) class Thresholds: queue_time_threshold_seconds: int = 300 crowded_count_threshold: int = 5 normal_count_threshold: int = 2 pause_timeout_seconds: int = 300 alert_cooldown_seconds: int = 600 @dataclass(slots=True) class StreamConfig: rtsp_url: str = "" sample_fps: float = 2.0 reconnect_backoff_seconds: float = 5.0 @dataclass(slots=True) class StaffConfig: gallery_dir: str = "data/staff_gallery" min_hits: int = 3 similarity_threshold: float = 0.9 @dataclass(slots=True) class WebhookConfig: url: str = "" alert_url: str = "" report_url: str = "" timeout_seconds: float = 5.0 @dataclass(slots=True) class EventSinkConfig: path: str = "logs/events.jsonl" @dataclass(slots=True) class AppConfig: camera_id: str timezone: str = "Asia/Shanghai" thresholds: Thresholds = field(default_factory=Thresholds) stream: StreamConfig = field(default_factory=StreamConfig) staff: StaffConfig = field(default_factory=StaffConfig) event_sink: EventSinkConfig = field(default_factory=EventSinkConfig) webhook: WebhookConfig = field(default_factory=WebhookConfig) def _load_section(raw: dict, key: str, cls): payload = dict(raw.get(key, {})) if cls is Thresholds: if ( "queue_time_threshold_seconds" not in payload and "min_dwell_seconds" in payload ): payload["queue_time_threshold_seconds"] = payload["min_dwell_seconds"] if "crowded_count_threshold" not in payload and "min_people" in payload: payload["crowded_count_threshold"] = payload["min_people"] return cls(**payload) def resolve_config_path(config_path: str | Path | None = None) -> Path: if config_path is not None: return Path(config_path).expanduser().resolve() project_root = Path(__file__).resolve().parent.parent local_path = project_root / "config" / "108.local.yaml" if local_path.exists(): return local_path return project_root / "config" / "config.example.yaml" def resolve_project_root(config_path: str | Path) -> Path: return Path(config_path).expanduser().resolve().parent.parent def resolve_project_path(project_root: str | Path, raw_path: str | Path) -> Path: path = Path(raw_path) if path.is_absolute(): return path.resolve() return (Path(project_root).resolve() / path).resolve() def load_config_document(path: Path) -> dict: return yaml.safe_load(path.read_text(encoding="utf-8")) or {} def load_config(path: Path) -> AppConfig: raw = load_config_document(path) return AppConfig( camera_id=raw["camera_id"], timezone=raw.get("timezone", "Asia/Shanghai"), thresholds=_load_section(raw, "thresholds", Thresholds), stream=_load_section(raw, "stream", StreamConfig), staff=_load_section(raw, "staff", StaffConfig), event_sink=_load_section(raw, "event_sink", EventSinkConfig), webhook=_load_section(raw, "webhook", WebhookConfig), ) def save_config_document(path: Path, raw: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temp_path = path.with_suffix(path.suffix + ".tmp") temp_path.write_text( yaml.safe_dump(raw, allow_unicode=True, sort_keys=False), encoding="utf-8", ) temp_path.replace(path)