Files
Behavision/behavision/cameras.py
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

195 lines
7.3 KiB
Python

"""Writable camera list — the store behind "user connects their camera".
Cameras used to live in `config/default.yaml` with credentials in `.env`, which
means adding one is an edit-and-restart. A product needs them added at runtime
from a UI, so they move here: a small JSON file the API can rewrite safely
while the engine is running.
Deliberately NOT stored in `behavision.db`. That file is a biometric database
with its own handling and erasure obligations; folding user-editable config
into it makes both harder to reason about, to back up, and to hand to support.
Passwords are protected at rest with Windows DPAPI. This is not theatre: the
file sits on the same disk as the face gallery, and an RTSP credential is a
live path into the camera itself.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import threading
from pathlib import Path
from typing import Optional
from .config import CameraConfig
log = logging.getLogger(__name__)
_PLAIN = "plain:"
_DPAPI = "dpapi:"
# Machine scope, not user scope. The service (LocalSystem) and an admin running
# the CLI are different accounts, and a user-scoped blob written by one cannot
# be read by the other — a failure that only shows up after install, on the
# customer's machine. Machine scope still defends the actual threat here:
# someone copying cameras.json off the box.
_CRYPTPROTECT_LOCAL_MACHINE = 0x04
def _win32crypt():
try:
import win32crypt # type: ignore
return win32crypt
except ImportError:
return None
def protect(value: str) -> str:
"""Encrypt a secret for storage. Tagged so the format can change later."""
if not value:
return ""
crypt = _win32crypt()
if crypt is None:
return _PLAIN + value
try:
blob = crypt.CryptProtectData(value.encode("utf-8"), "behavision",
None, None, None,
_CRYPTPROTECT_LOCAL_MACHINE)
return _DPAPI + base64.b64encode(blob).decode("ascii")
except Exception:
log.warning("DPAPI unavailable - storing camera password unencrypted",
exc_info=True)
return _PLAIN + value
def unprotect(stored: str) -> str:
"""Inverse of `protect`. Never raises: a credential that cannot be read is
an empty credential, so one unreadable camera does not stop the engine."""
if not stored:
return ""
if stored.startswith(_PLAIN):
return stored[len(_PLAIN):]
if stored.startswith(_DPAPI):
crypt = _win32crypt()
if crypt is None:
log.error("camera password is DPAPI-encrypted but win32crypt is "
"unavailable - re-enter it on this machine")
return ""
try:
return crypt.CryptUnprotectData(
base64.b64decode(stored[len(_DPAPI):]),
None, None, None, 0)[1].decode("utf-8")
except Exception:
log.error("camera password could not be decrypted (config copied "
"from another machine?) - re-enter it", exc_info=True)
return ""
return stored # pre-tag file written before this module existed
class CameraStore:
"""Cameras as JSON, safe to rewrite while the engine is running."""
def __init__(self, path: "Path | str"):
self.path = Path(path)
self._lock = threading.RLock()
self._cameras: "dict[str, CameraConfig]" = {}
self._load()
# -- persistence ----------------------------------------------------
def _load(self) -> None:
if not self.path.exists():
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
log.exception("%s is unreadable - starting with no cameras "
"(the file is left in place, not overwritten)",
self.path)
return
for entry in raw.get("cameras", []):
try:
entry = dict(entry)
entry["password"] = unprotect(entry.get("password", ""))
cam = CameraConfig.model_validate(entry)
except Exception:
log.exception("skipping malformed camera entry %r", entry)
continue
self._cameras[cam.id] = cam
def _save(self) -> None:
"""Atomic: a crash mid-write must not leave a truncated camera list."""
payload = {"version": 1, "cameras": []}
for cam in self._cameras.values():
entry = cam.model_dump(mode="json")
entry["password"] = protect(cam.password)
payload["cameras"].append(entry)
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError: # best effort (Windows)
pass
os.replace(tmp, self.path) # atomic on POSIX and NTFS
# -- CRUD -----------------------------------------------------------
def list(self) -> "list[CameraConfig]":
with self._lock:
return list(self._cameras.values())
def get(self, camera_id: str) -> Optional[CameraConfig]:
with self._lock:
return self._cameras.get(camera_id)
def add(self, camera: CameraConfig) -> CameraConfig:
with self._lock:
if camera.id in self._cameras:
raise ValueError(f"camera '{camera.id}' already exists")
camera.source() # validate now, not at connect time
self._cameras[camera.id] = camera
self._save()
return camera
def update(self, camera_id: str, fields: dict) -> Optional[CameraConfig]:
with self._lock:
existing = self._cameras.get(camera_id)
if existing is None:
return None
# id is the engine's key for the worker; renaming would orphan it
fields = {k: v for k, v in fields.items()
if k != "id" and v is not None}
# Re-validated rather than model_copy(update=...): copy does not
# coerce, so a nested `tuning` arriving as a plain dict from JSON
# would be stored as a dict and blow up the first time a camera
# asked it for its thresholds.
updated = CameraConfig.model_validate(
{**existing.model_dump(), **fields})
updated.source()
self._cameras[camera_id] = updated
self._save()
return updated
def delete(self, camera_id: str) -> bool:
with self._lock:
if self._cameras.pop(camera_id, None) is None:
return False
self._save()
return True
def seed(self, cameras: "list[CameraConfig]") -> bool:
"""Import YAML-declared cameras on first run only.
After that the store is authoritative — otherwise a camera the user
deleted in the UI would reappear on every restart.
"""
with self._lock:
if self.path.exists() or not cameras:
return False
for cam in cameras:
self._cameras[cam.id] = cam
self._save()
log.info("seeded %d camera(s) from YAML into %s",
len(cameras), self.path)
return True