Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "5.0.3"
VERSION = "5.0.4"
4 changes: 3 additions & 1 deletion app/backfill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion app/camera_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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)
39 changes: 37 additions & 2 deletions app/mqtt_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion app/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 []
)
Expand Down Expand Up @@ -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():
Expand Down
12 changes: 12 additions & 0 deletions faceid-addon/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion faceid-addon/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "5.0.3"
VERSION = "5.0.4"
4 changes: 3 additions & 1 deletion faceid-addon/app/backfill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion faceid-addon/app/camera_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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)
39 changes: 37 additions & 2 deletions faceid-addon/app/mqtt_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading