diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e16a1f..722da98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.0.4 — 2026-08-11 + +- **Camera participation from the UI:** every Frigate camera can now be enabled or + paused directly from Camera Studio. All cameras remain enabled by default. +- **Complete pause behavior:** a paused camera is excluded from live MQTT events, + polling, queued recognition jobs and history backfill; its stale presence state is + cleared immediately. Manual frame inspection remains available for setup. +- **Clear operational feedback:** camera cards show active/paused status, totals and + an “enable all” action, with confirmation after every change. +- **Persisted and compatible:** selections survive restarts, while the legacy YAML + camera allow-list remains a fallback until an explicit UI selection is saved. + ## 5.0.3 — 2026-08-11 - **Face-size control in Liveness:** every camera card now has a 24–240px visual diff --git a/app/__init__.py b/app/__init__.py index f602eb8..aca1843 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -VERSION = "5.0.3" +VERSION = "5.0.4" diff --git a/app/backfill.py b/app/backfill.py index 6e7bf11..a4031e1 100644 --- a/app/backfill.py +++ b/app/backfill.py @@ -25,7 +25,7 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, rescue_min_px: int = 90, rescue_min_det: float = 0.85, match_margin: float = 0.08, min_confirmations: int = 2, ignore_thr: float = 0.5, ignore_margin: float = 0.12, - unknown_thr: float = 0.35) -> dict: + unknown_thr: float = 0.35, camera_enabled=None) -> dict: """Person-Events der letzten `days` Tage verarbeiten. Threadsafe zur Live-Pipeline (Engine und Galerie sind intern gelockt). progress(i, total) wird pro Event gerufen. @@ -54,6 +54,8 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, if len(batch) < 100: break + if camera_enabled is not None: + events = [event for event in events if camera_enabled(event.get("camera", ""))] stats = {"events": len(events), "faces": 0, "no_face": 0, "dupe": 0, "known": 0, "ignored": 0} for i, ev in enumerate(events): if progress: diff --git a/app/camera_profiles.py b/app/camera_profiles.py index 5a09923..dae8237 100644 --- a/app/camera_profiles.py +++ b/app/camera_profiles.py @@ -32,6 +32,7 @@ def get(self, camera: str) -> dict: mode = stored.get("mode", "intercom" if stored.get("role") == "intercom" else "standard") return { "camera": camera, + "enabled": bool(stored.get("enabled", True)), "min_face_px": int(stored.get("min_face_px", self.default_min_face_px)), "role": stored.get("role", "observation"), "mode": mode if mode in MODES else "standard", @@ -50,7 +51,7 @@ def update( mode: str = "standard", night_min_face_px: int | None = None, burst_frames: int = 8, high_resolution: bool = False, require_second_factor: bool = True, liveness_mode: str | None = None, - roi: list | None = None, + roi: list | None = None, enabled: bool | None = None, ) -> dict: camera = str(camera).strip() if not camera: @@ -77,7 +78,9 @@ def update( raise ValueError("roi must describe a non-empty normalized rectangle") with self._lock: profiles = self._read() + stored = profiles.get(camera) or {} profiles[camera] = { + "enabled": bool(stored.get("enabled", True) if enabled is None else enabled), "min_face_px": min_face_px, "role": role, "mode": mode, "night_min_face_px": night_min_face_px, "burst_frames": burst_frames, @@ -92,3 +95,20 @@ def update( ) temporary.replace(self.path) return self.get(camera) + + def set_enabled(self, camera: str, enabled: bool) -> dict: + """Persist the automatic-processing switch without changing tuning.""" + camera = str(camera).strip() + if not camera: + raise ValueError("camera is required") + with self._lock: + profiles = self._read() + stored = profiles.get(camera) + profiles[camera] = dict(stored) if isinstance(stored, dict) else {} + profiles[camera]["enabled"] = bool(enabled) + temporary = self.path.with_suffix(".tmp") + temporary.write_text( + json.dumps(profiles, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temporary.replace(self.path) + return self.get(camera) diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index 00a276b..33c89ed 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -159,6 +159,25 @@ def start(self): if self.poll_interval > 0: threading.Thread(target=self._poller, daemon=True, name="faceid-poller").start() + def camera_enabled(self, camera: str) -> bool: + """Return the live UI override, falling back to the legacy allow-list.""" + if self.camera_profiles is not None: + stored = self.camera_profiles.all().get(camera) + if isinstance(stored, dict) and "enabled" in stored: + return bool(stored["enabled"]) + return not self.cameras or camera in self.cameras + + def set_camera_enabled(self, camera: str, enabled: bool) -> dict: + if self.camera_profiles is None: + raise RuntimeError("Camera profiles are unavailable") + profile = self.camera_profiles.set_enabled(camera, enabled) + if not enabled: + self.present[camera] = {} + self._last_presence.pop(camera, None) + self._publish_presence(camera) + log.info("camera %s automatic analysis %s", camera, "enabled" if enabled else "disabled") + return profile + def _job_dispatcher(self): while True: time.sleep(2) @@ -169,6 +188,12 @@ def _dispatch_pending_jobs(self): return for job in self.audit.pending_jobs(): eid, kind = job["event_id"], job["kind"] + if not self.camera_enabled(job["camera"]): + self.audit.finalize( + eid, "camera_disabled", end_ts=job.get("end_ts") or time.time() + ) + self.audit.complete_job(eid, kind) + continue if eid not in self.events: self.events[eid] = self._new_event_state( eid, job["camera"], start_time=job.get("start_ts"), @@ -252,7 +277,7 @@ def _on_message(self, client, userdata, msg): if after.get("label") != "person": return cam = after.get("camera", "") - if self.cameras and cam not in self.cameras: + if not self.camera_enabled(cam): return eid = after.get("id") if not eid: @@ -290,6 +315,16 @@ def _worker(self): try: if self.audit: self.audit.mark_job_running(eid, kind) + state = self.events.get(eid) + if state is not None and not self.camera_enabled(state["camera"]): + if self.audit: + self.audit.finalize( + eid, "camera_disabled", + end_ts=state.get("end_time") or time.time(), + ) + self.audit.complete_job(eid, kind) + state["done"] = True + continue if kind == "clip": self._process_clip(eid) else: @@ -576,7 +611,7 @@ def _poller(self): or (self.audit and self.audit.was_finalized(eid))): continue cam = ev.get("camera", "") - if self.cameras and cam not in self.cameras: + if not self.camera_enabled(cam): continue self._polled.append(eid) self._ensure_discovery(cam) diff --git a/app/webui.py b/app/webui.py index 046391c..0c56d67 100644 --- a/app/webui.py +++ b/app/webui.py @@ -429,7 +429,8 @@ def worker(): "ignore_threshold", cfg["faceid"].get("match_threshold", 0.5))), ignore_margin=float(cfg["faceid"].get("ignore_margin", 0.12)), progress=progress, - hires=bool(cfg["faceid"].get("hires_enroll", True))) + hires=bool(cfg["faceid"].get("hires_enroll", True)), + camera_enabled=processor.camera_enabled) backfill_state["result"] = stats backfill_state["status"] = "completed" except InterruptedError as e: @@ -1135,6 +1136,7 @@ def dashboard(): class CameraProfileBody(BaseModel): min_face_px: int + enabled: bool | None = None role: str = "observation" mode: str = "standard" night_min_face_px: int | None = None @@ -1168,6 +1170,7 @@ def camera_studio(days: int = 7): "camera": camera, "min_face_px": processor.min_face_px, "role": "observation", } + profile["enabled"] = processor.camera_enabled(camera) samples = ( processor.audit.camera_samples(camera, 24) if processor.audit else [] ) @@ -1197,10 +1200,30 @@ def save_camera_profile(camera: str, body: CameraProfileBody): require_second_factor=body.require_second_factor, liveness_mode=body.liveness_mode, roi=body.roi, + enabled=body.enabled, )} except ValueError as exc: raise HTTPException(400, str(exc)) from exc + class CameraEnabledBody(BaseModel): + enabled: bool + + @app.post("/api/cameras/{camera}/enabled") + def set_camera_enabled(camera: str, body: CameraEnabledBody): + if camera not in _camera_names(): + raise HTTPException(404, "Unknown camera") + try: + profile = processor.set_camera_enabled(camera, body.enabled) + except (RuntimeError, ValueError) as exc: + raise HTTPException(503, str(exc)) from exc + profile["enabled"] = processor.camera_enabled(camera) + return { + "ok": True, + "camera": camera, + "enabled": profile["enabled"], + "profile": profile, + } + @app.get("/api/cameras/{camera}/frame") def camera_frame(camera: str): if camera not in _camera_names(): diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 4e16a1f..722da98 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 5.0.4 — 2026-08-11 + +- **Camera participation from the UI:** every Frigate camera can now be enabled or + paused directly from Camera Studio. All cameras remain enabled by default. +- **Complete pause behavior:** a paused camera is excluded from live MQTT events, + polling, queued recognition jobs and history backfill; its stale presence state is + cleared immediately. Manual frame inspection remains available for setup. +- **Clear operational feedback:** camera cards show active/paused status, totals and + an “enable all” action, with confirmation after every change. +- **Persisted and compatible:** selections survive restarts, while the legacy YAML + camera allow-list remains a fallback until an explicit UI selection is saved. + ## 5.0.3 — 2026-08-11 - **Face-size control in Liveness:** every camera card now has a 24–240px visual diff --git a/faceid-addon/app/__init__.py b/faceid-addon/app/__init__.py index f602eb8..aca1843 100644 --- a/faceid-addon/app/__init__.py +++ b/faceid-addon/app/__init__.py @@ -1 +1 @@ -VERSION = "5.0.3" +VERSION = "5.0.4" diff --git a/faceid-addon/app/backfill.py b/faceid-addon/app/backfill.py index 6e7bf11..a4031e1 100644 --- a/faceid-addon/app/backfill.py +++ b/faceid-addon/app/backfill.py @@ -25,7 +25,7 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, rescue_min_px: int = 90, rescue_min_det: float = 0.85, match_margin: float = 0.08, min_confirmations: int = 2, ignore_thr: float = 0.5, ignore_margin: float = 0.12, - unknown_thr: float = 0.35) -> dict: + unknown_thr: float = 0.35, camera_enabled=None) -> dict: """Person-Events der letzten `days` Tage verarbeiten. Threadsafe zur Live-Pipeline (Engine und Galerie sind intern gelockt). progress(i, total) wird pro Event gerufen. @@ -54,6 +54,8 @@ def run_backfill(engine, gallery, frigate, frigate_url: str, days: int = 14, if len(batch) < 100: break + if camera_enabled is not None: + events = [event for event in events if camera_enabled(event.get("camera", ""))] stats = {"events": len(events), "faces": 0, "no_face": 0, "dupe": 0, "known": 0, "ignored": 0} for i, ev in enumerate(events): if progress: diff --git a/faceid-addon/app/camera_profiles.py b/faceid-addon/app/camera_profiles.py index 5a09923..dae8237 100644 --- a/faceid-addon/app/camera_profiles.py +++ b/faceid-addon/app/camera_profiles.py @@ -32,6 +32,7 @@ def get(self, camera: str) -> dict: mode = stored.get("mode", "intercom" if stored.get("role") == "intercom" else "standard") return { "camera": camera, + "enabled": bool(stored.get("enabled", True)), "min_face_px": int(stored.get("min_face_px", self.default_min_face_px)), "role": stored.get("role", "observation"), "mode": mode if mode in MODES else "standard", @@ -50,7 +51,7 @@ def update( mode: str = "standard", night_min_face_px: int | None = None, burst_frames: int = 8, high_resolution: bool = False, require_second_factor: bool = True, liveness_mode: str | None = None, - roi: list | None = None, + roi: list | None = None, enabled: bool | None = None, ) -> dict: camera = str(camera).strip() if not camera: @@ -77,7 +78,9 @@ def update( raise ValueError("roi must describe a non-empty normalized rectangle") with self._lock: profiles = self._read() + stored = profiles.get(camera) or {} profiles[camera] = { + "enabled": bool(stored.get("enabled", True) if enabled is None else enabled), "min_face_px": min_face_px, "role": role, "mode": mode, "night_min_face_px": night_min_face_px, "burst_frames": burst_frames, @@ -92,3 +95,20 @@ def update( ) temporary.replace(self.path) return self.get(camera) + + def set_enabled(self, camera: str, enabled: bool) -> dict: + """Persist the automatic-processing switch without changing tuning.""" + camera = str(camera).strip() + if not camera: + raise ValueError("camera is required") + with self._lock: + profiles = self._read() + stored = profiles.get(camera) + profiles[camera] = dict(stored) if isinstance(stored, dict) else {} + profiles[camera]["enabled"] = bool(enabled) + temporary = self.path.with_suffix(".tmp") + temporary.write_text( + json.dumps(profiles, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temporary.replace(self.path) + return self.get(camera) diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index 00a276b..33c89ed 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -159,6 +159,25 @@ def start(self): if self.poll_interval > 0: threading.Thread(target=self._poller, daemon=True, name="faceid-poller").start() + def camera_enabled(self, camera: str) -> bool: + """Return the live UI override, falling back to the legacy allow-list.""" + if self.camera_profiles is not None: + stored = self.camera_profiles.all().get(camera) + if isinstance(stored, dict) and "enabled" in stored: + return bool(stored["enabled"]) + return not self.cameras or camera in self.cameras + + def set_camera_enabled(self, camera: str, enabled: bool) -> dict: + if self.camera_profiles is None: + raise RuntimeError("Camera profiles are unavailable") + profile = self.camera_profiles.set_enabled(camera, enabled) + if not enabled: + self.present[camera] = {} + self._last_presence.pop(camera, None) + self._publish_presence(camera) + log.info("camera %s automatic analysis %s", camera, "enabled" if enabled else "disabled") + return profile + def _job_dispatcher(self): while True: time.sleep(2) @@ -169,6 +188,12 @@ def _dispatch_pending_jobs(self): return for job in self.audit.pending_jobs(): eid, kind = job["event_id"], job["kind"] + if not self.camera_enabled(job["camera"]): + self.audit.finalize( + eid, "camera_disabled", end_ts=job.get("end_ts") or time.time() + ) + self.audit.complete_job(eid, kind) + continue if eid not in self.events: self.events[eid] = self._new_event_state( eid, job["camera"], start_time=job.get("start_ts"), @@ -252,7 +277,7 @@ def _on_message(self, client, userdata, msg): if after.get("label") != "person": return cam = after.get("camera", "") - if self.cameras and cam not in self.cameras: + if not self.camera_enabled(cam): return eid = after.get("id") if not eid: @@ -290,6 +315,16 @@ def _worker(self): try: if self.audit: self.audit.mark_job_running(eid, kind) + state = self.events.get(eid) + if state is not None and not self.camera_enabled(state["camera"]): + if self.audit: + self.audit.finalize( + eid, "camera_disabled", + end_ts=state.get("end_time") or time.time(), + ) + self.audit.complete_job(eid, kind) + state["done"] = True + continue if kind == "clip": self._process_clip(eid) else: @@ -576,7 +611,7 @@ def _poller(self): or (self.audit and self.audit.was_finalized(eid))): continue cam = ev.get("camera", "") - if self.cameras and cam not in self.cameras: + if not self.camera_enabled(cam): continue self._polled.append(eid) self._ensure_discovery(cam) diff --git a/faceid-addon/app/webui.py b/faceid-addon/app/webui.py index 046391c..0c56d67 100644 --- a/faceid-addon/app/webui.py +++ b/faceid-addon/app/webui.py @@ -429,7 +429,8 @@ def worker(): "ignore_threshold", cfg["faceid"].get("match_threshold", 0.5))), ignore_margin=float(cfg["faceid"].get("ignore_margin", 0.12)), progress=progress, - hires=bool(cfg["faceid"].get("hires_enroll", True))) + hires=bool(cfg["faceid"].get("hires_enroll", True)), + camera_enabled=processor.camera_enabled) backfill_state["result"] = stats backfill_state["status"] = "completed" except InterruptedError as e: @@ -1135,6 +1136,7 @@ def dashboard(): class CameraProfileBody(BaseModel): min_face_px: int + enabled: bool | None = None role: str = "observation" mode: str = "standard" night_min_face_px: int | None = None @@ -1168,6 +1170,7 @@ def camera_studio(days: int = 7): "camera": camera, "min_face_px": processor.min_face_px, "role": "observation", } + profile["enabled"] = processor.camera_enabled(camera) samples = ( processor.audit.camera_samples(camera, 24) if processor.audit else [] ) @@ -1197,10 +1200,30 @@ def save_camera_profile(camera: str, body: CameraProfileBody): require_second_factor=body.require_second_factor, liveness_mode=body.liveness_mode, roi=body.roi, + enabled=body.enabled, )} except ValueError as exc: raise HTTPException(400, str(exc)) from exc + class CameraEnabledBody(BaseModel): + enabled: bool + + @app.post("/api/cameras/{camera}/enabled") + def set_camera_enabled(camera: str, body: CameraEnabledBody): + if camera not in _camera_names(): + raise HTTPException(404, "Unknown camera") + try: + profile = processor.set_camera_enabled(camera, body.enabled) + except (RuntimeError, ValueError) as exc: + raise HTTPException(503, str(exc)) from exc + profile["enabled"] = processor.camera_enabled(camera) + return { + "ok": True, + "camera": camera, + "enabled": profile["enabled"], + "profile": profile, + } + @app.get("/api/cameras/{camera}/frame") def camera_frame(camera: str): if camera not in _camera_names(): diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index abf13e4..85290c8 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "5.0.3" +version: "5.0.4" slug: faceid description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors url: https://github.com/r11a/faceid @@ -12,7 +12,7 @@ boot: auto init: false ingress: true ingress_port: 8600 -ingress_entry: ui-5.0.3 +ingress_entry: ui-5.0.4 panel_icon: mdi:face-recognition panel_title: FaceID ports: diff --git a/faceid-addon/static/index.html b/faceid-addon/static/index.html index e84f6f5..3e13b52 100644 --- a/faceid-addon/static/index.html +++ b/faceid-addon/static/index.html @@ -114,6 +114,9 @@ .camera-list{display:grid;gap:9px;position:sticky;top:130px} .camera-choice{width:100%;text-align:right;padding:14px;border:1px solid var(--line);background:var(--panel);color:var(--fg);display:flex;justify-content:space-between;align-items:center} .camera-choice.on{border-color:var(--acc);background:color-mix(in oklab,var(--panel2) 80%,var(--acc) 20%)} +.camera-choice.off{opacity:.62}.camera-choice.off b{text-decoration:line-through;text-decoration-thickness:1px} +.camera-state{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800}.camera-state:before{content:'';width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 0 3px color-mix(in oklab,var(--good) 18%,transparent)} +.camera-state.off{color:var(--dim)}.camera-state.off:before{background:#777;box-shadow:none} .studio-panel{border:1px solid var(--line);border-radius:20px;background:linear-gradient(145deg,#25272a,#18191b);overflow:hidden} .camera-stage{position:relative;background:#090a0b;min-height:260px;display:grid;place-items:center;overflow:hidden} .camera-stage img{display:block;width:100%;max-height:62vh;object-fit:contain} @@ -261,7 +264,7 @@

FaceID מרכז