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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
All notable changes to FaceID. The Home Assistant app shows this file in the
update dialog; standalone users can watch GitHub releases.

## 5.2.1 — 2026-08-12

- **Bounded review inbox:** the verification screen keeps at most 200 representative
crops, 12 per likely identity and 14 days. Existing oversized queues are reduced
automatically on startup without deleting event metadata or investigation history.
- **Verify without polluting enrollment:** confirming a known person now labels and
closes all related tasks without copying every crop into the person's reference
gallery. Adding a training image is a separate, explicit action capped at three.
- **Visit-aware recognition:** the first sighting is an arrival, a move to another
camera is a transition and continuing visibility on the same camera is a quiet
presence update. Quiet updates refresh HA presence/last-seen state but do not create
duplicate notifications, automations, AI jobs, body-training material or statistics.
- **Hard evidence limits:** investigation images are capped at 300 known and 300 other
events, decoded-frame cache at 25 events and optional AI grids at 100. Event time,
score and decision metadata remain after transient imagery is removed.
- **Friendlier review UI:** explains the automatic limits, uses clear primary actions,
moves destructive options behind disclosure and provides manual cleanup feedback.

## 5.2.0 — 2026-08-12

- **Five stable product areas:** Home, Events, People, Cameras and Management replace
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.2.0"
VERSION = "5.2.1"
77 changes: 62 additions & 15 deletions app/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ def __init__(self, path: Path, retention_days: int = 90):
self.evidence_dir = self.path.parent / "audit_images"
self.evidence_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self.evidence_known_days = 30
self.evidence_unknown_days = 14
self.evidence_known_max = 300
self.evidence_unknown_max = 300
self._evidence_since_prune = 0
self._init_db()
self.prune(retention_days)

Expand All @@ -38,6 +43,13 @@ def save_evidence(self, event_id: str, image) -> Path | None:
temporary = target.with_suffix(".tmp")
temporary.write_bytes(encoded.tobytes())
temporary.replace(target)
self._evidence_since_prune += 1
if self._evidence_since_prune >= 25:
self._evidence_since_prune = 0
self.prune_evidence(
self.evidence_known_days, self.evidence_unknown_days,
self.evidence_known_max, self.evidence_unknown_max,
)
return target
except (OSError, ValueError):
return None
Expand Down Expand Up @@ -145,6 +157,7 @@ def _init_db(self):
("ground_truth_by", "TEXT"),
("liveness_status", "TEXT"),
("liveness_score", "REAL"),
("occurrence", "TEXT"),
):
self._ensure_column(con, "events", column, declaration)

Expand Down Expand Up @@ -246,12 +259,13 @@ def finalize(
score: float = 0.0,
margin: float = 0.0,
confirmations: int = 0,
occurrence: str | None = None,
):
with self._lock, self._connection() as con:
con.execute(
"""
UPDATE events SET end_ts=?, status=?, person=?, score=?, margin=?,
confirmations=?, updated_ts=?
confirmations=?, occurrence=?, updated_ts=?
WHERE event_id=?
""",
(
Expand All @@ -261,6 +275,7 @@ def finalize(
score,
margin,
confirmations,
occurrence,
time.time(),
event_id,
),
Expand Down Expand Up @@ -303,18 +318,25 @@ def prune(self, retention_days: int):
except OSError:
pass

def recent(self, limit: int = 100, status: str | None = None):
def recent(
self, limit: int = 100, status: str | None = None,
include_presence_updates: bool = False,
):
sql = """
SELECT event_id, camera, start_ts, end_ts, status, person, score,
margin, confirmations, updated_ts, ground_truth, scenario_id,
ai_description, ai_tags, probable_person, probable_score,
liveness_status, liveness_score
liveness_status, liveness_score, occurrence
FROM events
"""
params = []
clauses, params = [], []
if not include_presence_updates:
clauses.append("COALESCE(occurrence,'')!='presence_update'")
if status:
sql += " WHERE status=?"
clauses.append("status=?")
params.append(status)
if clauses:
sql += f" WHERE {' AND '.join(clauses)}"
sql += " ORDER BY updated_ts DESC LIMIT ?"
params.append(max(1, min(int(limit), 500)))
with self._lock, self._connection() as con:
Expand All @@ -326,8 +348,11 @@ def search_events(
status: str | None = None, person: str | None = None,
camera: str | None = None, date_from: float | None = None,
date_to: float | None = None, query: str | None = None,
include_presence_updates: bool = False,
):
clauses, params = [], []
if not include_presence_updates:
clauses.append("COALESCE(occurrence,'')!='presence_update'")
if status:
clauses.append("status=?")
params.append(status)
Expand Down Expand Up @@ -357,7 +382,8 @@ def search_events(
event_id, camera, start_ts, end_ts, status, person, score, margin,
confirmations, updated_ts, ground_truth, ground_truth_ts,
ground_truth_by, scenario_id, ai_description, ai_tags,
probable_person, probable_score, liveness_status, liveness_score
probable_person, probable_score, liveness_status, liveness_score,
occurrence
"""
with self._lock, self._connection() as con:
con.row_factory = sqlite3.Row
Expand Down Expand Up @@ -507,6 +533,7 @@ def person_profile(
con.row_factory = sqlite3.Row
recognition_times = con.execute(
"SELECT start_ts FROM events WHERE status='recognized' "
"AND COALESCE(occurrence,'')!='presence_update' "
"AND person=? AND start_ts IS NOT NULL",
(person,),
).fetchall()
Expand Down Expand Up @@ -561,6 +588,7 @@ def system_report(self):
SUM(CASE WHEN status IN ('unknown','ambiguous') THEN 1 ELSE 0 END) review,
AVG(CASE WHEN score>0 THEN score END) avg_score
FROM events WHERE status!='processing' AND start_ts>=?
AND COALESCE(occurrence,'')!='presence_update'
GROUP BY camera ORDER BY events DESC
""", (time.time() - 7 * 86400,)
).fetchall()
Expand All @@ -582,7 +610,8 @@ def recognized_timeline(
self, *, person: str | None = None, after_ts: float = 0
) -> list[dict]:
"""Chronological recognized events used to derive visits, not raw frame counts."""
where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?"]
where = ["status='recognized'", "person IS NOT NULL", "start_ts>=?",
"COALESCE(occurrence,'')!='presence_update'"]
params: list = [float(after_ts)]
if person:
where.append("person=?")
Expand Down Expand Up @@ -620,6 +649,7 @@ def camera_funnels(self, days: int = 7) -> list[dict]:
AVG(CASE WHEN o.quality>0 THEN o.quality END) avg_quality
FROM events e LEFT JOIN observation_summary o ON o.event_id=e.event_id
WHERE e.status!='processing' AND e.start_ts>=?
AND COALESCE(e.occurrence,'')!='presence_update'
GROUP BY e.camera ORDER BY events DESC
""", (cutoff,)
).fetchall()
Expand Down Expand Up @@ -649,21 +679,34 @@ def camera_samples(self, camera: str, limit: int = 24) -> list[dict]:
).fetchall()
return [dict(row) for row in rows]

def prune_evidence(self, known_days: int, unknown_days: int) -> int:
"""Apply separate image retention without deleting recognition metadata."""
def prune_evidence(
self, known_days: int, unknown_days: int,
known_max: int = 300, unknown_max: int = 300,
) -> int:
"""Bound evidence by age and count without deleting event metadata."""
self.evidence_known_days = int(known_days)
self.evidence_unknown_days = int(unknown_days)
self.evidence_known_max = max(0, int(known_max))
self.evidence_unknown_max = max(0, int(unknown_max))
now = time.time()
removed = 0
with self._lock, self._connection() as con:
rows = con.execute(
"SELECT event_id, status, updated_ts FROM events "
"WHERE status!='processing'"
"WHERE status!='processing' ORDER BY updated_ts DESC"
).fetchall()
kept = {"known": 0, "unknown": 0}
for event_id, status, updated_ts in rows:
days = known_days if status == "recognized" else unknown_days
if days <= 0 or float(updated_ts or now) >= now - days * 86400:
continue
kind = "known" if status == "recognized" else "unknown"
days = known_days if kind == "known" else unknown_days
maximum = self.evidence_known_max if kind == "known" else self.evidence_unknown_max
path = self.evidence_path(event_id)
if path.is_file():
if not path.is_file():
continue
kept[kind] += 1
expired = days > 0 and float(updated_ts or now) < now - days * 86400
over_cap = maximum == 0 or kept[kind] > maximum
if expired or over_cap:
try:
path.unlink()
removed += 1
Expand Down Expand Up @@ -703,6 +746,7 @@ def person_statistics(self):
SUM(CASE WHEN start_ts>=? THEN 1 ELSE 0 END) AS last_30_days
FROM events
WHERE status='recognized' AND person IS NOT NULL
AND COALESCE(occurrence,'')!='presence_update'
GROUP BY person
""",
(now - 7 * 86400, now - 30 * 86400),
Expand All @@ -722,6 +766,7 @@ def person_statistics(self):
"""
SELECT camera, COUNT(*) AS count FROM events
WHERE status='recognized' AND person=?
AND COALESCE(occurrence,'')!='presence_update'
GROUP BY camera ORDER BY count DESC, camera
""",
(row["person"],),
Expand All @@ -745,6 +790,7 @@ def dashboard_summary(self):
SELECT status, COUNT(*) AS count FROM events
WHERE date(start_ts, 'unixepoch', 'localtime')
= date('now', 'localtime')
AND COALESCE(occurrence,'')!='presence_update'
GROUP BY status
"""
).fetchall()
Expand All @@ -765,7 +811,8 @@ def traffic_events(self, *, after_ts: float, limit: int = 10000) -> list[dict]:
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT event_id, camera, start_ts, end_ts, status FROM events "
"WHERE start_ts>=? AND status!='processing' ORDER BY start_ts LIMIT ?",
"WHERE start_ts>=? AND status!='processing' "
"AND COALESCE(occurrence,'')!='presence_update' ORDER BY start_ts LIMIT ?",
(float(after_ts), max(1, min(int(limit), 100000))),
).fetchall()
return [dict(row) for row in rows]
Expand Down
10 changes: 8 additions & 2 deletions app/frame_distributor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def __init__(self, data_dir: Path, media_store, *, decode_mode: str = "auto", ma
self.root.mkdir(parents=True, exist_ok=True)
self.decode_mode = decode_mode if decode_mode in ("auto", "software", "vaapi", "cuda") else "auto"
self.max_frames = max(4, min(int(max_frames), 120))
self.max_cached_events = 25
self._lock = threading.Lock()
self._stats = {"requests": 0, "cache_hits": 0, "ffmpeg": 0, "opencv": 0,
"hardware": 0, "fallbacks": 0, "last_backend": None, "last_error": None}
Expand Down Expand Up @@ -127,11 +128,16 @@ def report(self) -> dict:

def prune(self):
cutoff = time.time() - float(getattr(self.media_store, "retention_seconds", 86400))
for directory in self.root.iterdir():
directories = sorted(
(path for path in self.root.iterdir() if path.is_dir()),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for index, directory in enumerate(directories):
if not directory.is_dir():
continue
try:
if directory.stat().st_mtime >= cutoff:
if index < self.max_cached_events and directory.stat().st_mtime >= cutoff:
continue
for path in directory.iterdir():
path.unlink(missing_ok=True)
Expand Down
Loading
Loading