"""서버에서 내려온 임계값을 살아 있는 감지기에 적용한다. WHAT WAS BROKEN `edge/zones.py:thresholds_from_config()` 는 정의만 되어 있고 어디에서도 호출되지 않았다. 서버에는 `move_thresholds` 표가 있고, `GET /api/config` 가 그것을 성실히 실어 보내고, 엣지는 그 값을 파싱한 뒤 그대로 버렸다. 즉 배선의 양 끝이 다 있는데 가운데가 비어 있어서, 관리자가 무엇을 바꾸든 장비는 부팅 시 edge.env 값으로 영원히 돌았다. 이 파일이 그 가운데다. WHY VALIDATE HERE TOO 서버의 ThresholdCatalog 가 이미 범위를 강제하지만, 그것은 *편집 화면* 의 방어선이다. DB 를 직접 고친 값, 구버전 서버, 손상된 응답은 여전히 도달할 수 있고, `now - t >= limit` 비교에 NaN 이 들어가면 감지기가 조용히 꺼지고 0 이 들어가면 영구 알람이 된다 (edge/config.py `_positive` 의 주석과 같은 이유). 기동 시점의 검증은 여기서 반복된다. WHY NOT CRASH 기동 검증은 잘못된 값에 예외를 던진다 — 그때는 운영자가 화면 앞에 있다. 여기는 24/7 로 도는 동기화 경로다. 서버가 보낸 한 값이 이상하다고 공장 안 장비를 재시작 루프에 빠뜨리면 그 카메라는 그 순간부터 아무것도 못 본다. 나쁜 값은 무시하고 로그로 남기고, 나머지는 적용한다. """ import math # 서버 키 -> (대상 객체 경로, 속성명). 하나의 키가 두 곳에 적용되는 경우도 있다 # (motion_thr 은 fall 과 immobility 가 공유한다 — edge/config.py 가 같은 값을 둘에 넣는다). _TARGETS: dict[str, tuple[tuple[str, str], ...]] = { "fall_prone_s": (("fall", "prone_confirm_s"),), "fall_no_motion_s": (("fall", "no_motion_s"),), "immobile_s": (("immobility", "immobile_s"),), "ghost_reappear_s": (("ghost", "reappear_s"),), "zone_debounce_s": (("zone", "debounce_s"),), "cooldown_s": (("engine", "cooldown_s"),), "motion_thr": (("fall", "motion_thr"), ("immobility", "motion_thr")), } # 정수 노브는 별도 취급: 감지기 설정이 아니라 런타임 자체의 샘플링 주기다. _INT_KNOBS = {"infer_every"} def _clean(raw) -> float | None: """유한 양수만 통과. 그 외에는 None (호출부가 로그하고 무시).""" try: v = float(raw) except (TypeError, ValueError): return None if not math.isfinite(v) or v <= 0: return None return v def apply_thresholds(runtime, thresholds: dict, log=None) -> dict: """`runtime` 의 감지기 설정에 서버 임계값을 적용한다. @return 실제로 적용된 {key: value} — 호출부가 로그/테스트에 쓴다. """ log = log or (lambda m: None) if not isinstance(thresholds, dict) or not thresholds: return {} engine = getattr(runtime, "engine", None) if engine is None: return {} # 감지기별 설정 객체. 이름이 다른 구현이 오더라도 없는 것은 조용히 건너뛴다. holders = { "fall": getattr(getattr(engine, "fall", None), "cfg", None), "immobility": getattr(getattr(engine, "immobility", None), "cfg", None), "ghost": getattr(getattr(engine, "ghost", None), "cfg", None), "zone": getattr(getattr(engine, "zone", None), "cfg", None), "engine": getattr(engine, "cfg", None), } applied: dict[str, float] = {} for key, raw in thresholds.items(): if key in _INT_KNOBS: v = _clean(raw) if v is None or v != int(v): log(f"threshold {key}={raw!r} ignored (must be a positive integer)") continue # infer_every 는 런타임의 샘플링 주기. 상한을 두는 이유: 서버가 실수로 1000 을 # 보내면 그 카메라는 사실상 감시를 멈춘다. runtime.infer_every = max(1, min(10, int(v))) applied[key] = runtime.infer_every continue targets = _TARGETS.get(key) if targets is None: # 알 수 없는 키는 무시한다. 서버가 신버전이고 엣지가 구버전인 정상 상황이다. continue v = _clean(raw) if v is None: log(f"threshold {key}={raw!r} ignored (must be a finite positive number)") continue for holder_name, attr in targets: holder = holders.get(holder_name) if holder is None or not hasattr(holder, attr): continue setattr(holder, attr, v) applied[key] = v return applied