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
227 lines
7.7 KiB
Python
227 lines
7.7 KiB
Python
"""Camera CRUD over HTTP against a stub engine (no models, no camera).
|
|
|
|
Needs httpx for starlette's TestClient; skipped if absent so a bare checkout
|
|
still runs the suite.
|
|
"""
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("httpx")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from behavision.api import create_app # noqa: E402
|
|
from behavision.cameras import CameraStore # noqa: E402
|
|
from behavision.config import Config # noqa: E402
|
|
from behavision.engine import Engine # noqa: E402
|
|
|
|
SECRET = "sup3r-s3cret-rtsp-pw"
|
|
|
|
|
|
class FakeWorker:
|
|
def __init__(self, cam_cfg):
|
|
self.cam_cfg = cam_cfg
|
|
self.rcfg = Config().recognition.merged(cam_cfg.tuning)
|
|
self.commission = None
|
|
def start(self): pass
|
|
def stop(self): pass
|
|
def is_alive(self): return True
|
|
def join(self, timeout=None): pass
|
|
def stats(self): return {"camera_id": self.cam_cfg.id, "connected": True,
|
|
"url": self.cam_cfg.safe_url()}
|
|
def latest_jpeg(self): return None
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("behavision.engine.CameraWorker",
|
|
lambda cam, *a, **kw: FakeWorker(cam))
|
|
monkeypatch.setattr("behavision.engine.FaceDetector", lambda *a, **kw: object())
|
|
eng = object.__new__(Engine)
|
|
eng.cfg = Config()
|
|
eng.cfg.app.data_dir = tmp_path
|
|
eng.cfg.api.username = eng.cfg.api.password = "" # loopback: no auth
|
|
eng.encoder = eng.gallery = eng.bus = eng.attributes = eng.store = None
|
|
eng._lock = threading.RLock()
|
|
eng.workers, eng.detectors = {}, {}
|
|
eng.started_at, eng._running = 1.0, True
|
|
eng.camera_store = CameraStore(tmp_path / "cameras.json")
|
|
return TestClient(create_app(eng)), eng
|
|
|
|
|
|
def _add(c, cid="cam1", **kw):
|
|
body = dict(id=cid, host="192.168.0.138", path="/ch0_0.264",
|
|
username="admin", password=SECRET)
|
|
body.update(kw)
|
|
return c.post("/api/cameras", json=body)
|
|
|
|
|
|
def test_add_camera_starts_it_without_a_restart(client):
|
|
c, eng = client
|
|
r = _add(c)
|
|
assert r.status_code == 201
|
|
assert cid_in(c, "cam1")
|
|
assert "cam1" in eng.workers # running, not just stored
|
|
|
|
|
|
def cid_in(c, cid):
|
|
return any(x["id"] == cid for x in c.get("/api/cameras").json())
|
|
|
|
|
|
def test_password_never_appears_in_any_response(client):
|
|
"""The assertion this whole phase exists to satisfy."""
|
|
c, _ = client
|
|
_add(c)
|
|
for resp in (c.get("/api/cameras"),
|
|
c.patch("/api/cameras/cam1", json={"port": 8554}),
|
|
c.post("/api/cameras", json={"id": "cam2", "host": "10.0.0.5",
|
|
"password": SECRET})):
|
|
assert SECRET not in resp.text, resp.url
|
|
|
|
|
|
def test_listing_reports_password_presence_not_the_value(client):
|
|
c, _ = client
|
|
_add(c)
|
|
entry = c.get("/api/cameras").json()[0]
|
|
assert entry["has_password"] is True
|
|
assert "password" not in entry
|
|
assert "*****" in entry["url"]
|
|
|
|
|
|
def test_duplicate_id_conflicts(client):
|
|
c, _ = client
|
|
_add(c)
|
|
assert _add(c).status_code == 409
|
|
|
|
|
|
def test_camera_with_no_source_is_rejected(client):
|
|
c, eng = client
|
|
assert c.post("/api/cameras", json={"id": "bad"}).status_code == 400
|
|
assert eng.camera_store.list() == [] # nothing persisted
|
|
|
|
|
|
def test_id_is_required(client):
|
|
c, _ = client
|
|
assert c.post("/api/cameras", json={"host": "10.0.0.1"}).status_code == 400
|
|
|
|
|
|
def test_edit_restarts_the_worker(client):
|
|
c, eng = client
|
|
_add(c)
|
|
first = eng.workers["cam1"]
|
|
r = c.patch("/api/cameras/cam1", json={"host": "10.0.0.77"})
|
|
assert r.status_code == 200
|
|
assert eng.workers["cam1"] is not first
|
|
assert eng.camera_store.get("cam1").host == "10.0.0.77"
|
|
|
|
|
|
def test_edit_keeps_the_password_when_not_resent(client):
|
|
"""The UI never receives the password, so it cannot echo it back on save."""
|
|
c, eng = client
|
|
_add(c)
|
|
c.patch("/api/cameras/cam1", json={"port": 8554})
|
|
assert eng.camera_store.get("cam1").password == SECRET
|
|
|
|
|
|
def test_delete_removes_from_store_and_engine(client):
|
|
c, eng = client
|
|
_add(c)
|
|
assert c.delete("/api/cameras/cam1").status_code == 200
|
|
assert eng.camera_store.get("cam1") is None
|
|
assert "cam1" not in eng.workers
|
|
assert c.delete("/api/cameras/cam1").status_code == 404
|
|
|
|
|
|
def test_edit_unknown_camera_404s(client):
|
|
c, _ = client
|
|
assert c.patch("/api/cameras/nope", json={"port": 1}).status_code == 404
|
|
|
|
|
|
def test_test_endpoint_does_not_save(client):
|
|
c, eng = client
|
|
r = c.post("/api/cameras/test", json={"id": "probe", "host": "127.0.0.1",
|
|
"port": 1, "path": "/none"})
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is False # nothing listening on port 1
|
|
assert eng.camera_store.list() == [] # and nothing was persisted
|
|
|
|
|
|
def test_test_endpoint_reports_bad_input_instead_of_raising(client):
|
|
c, _ = client
|
|
body = c.post("/api/cameras/test", json={"id": "probe"}).json()
|
|
assert body["ok"] is False and "url, host or webcam" in body["error"]
|
|
|
|
|
|
# -- placement commissioning ------------------------------------------------
|
|
|
|
def test_commission_starts_and_reports_progress(client):
|
|
c, eng = client
|
|
_add(c)
|
|
r = c.post("/api/cameras/cam1/commission", json={"seconds": 30})
|
|
assert r.status_code == 200
|
|
assert r.json()["verdict"] == "running"
|
|
assert eng.workers["cam1"].commission is not None
|
|
|
|
again = c.get("/api/cameras/cam1/commission")
|
|
assert again.status_code == 200
|
|
assert again.json()["camera_id"] == "cam1"
|
|
|
|
|
|
def test_commission_judges_against_the_cameras_own_gate(client):
|
|
"""A per-camera loosened gate must drive the verdict, or the wizard would
|
|
grade an overhead camera against a threshold it never runs under."""
|
|
c, eng = client
|
|
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
|
|
r = c.post("/api/cameras/overhead/commission", json={"seconds": 30})
|
|
assert r.json()["gate"] == 0.40
|
|
|
|
|
|
def test_commission_on_unknown_camera_404s(client):
|
|
c, _ = client
|
|
assert c.post("/api/cameras/nope/commission",
|
|
json={"seconds": 5}).status_code == 404
|
|
assert c.get("/api/cameras/nope/commission").status_code == 404
|
|
|
|
|
|
def test_reading_a_check_that_never_ran_404s(client):
|
|
c, _ = client
|
|
_add(c)
|
|
assert c.get("/api/cameras/cam1/commission").status_code == 404
|
|
|
|
|
|
def test_commission_can_be_cancelled(client):
|
|
c, eng = client
|
|
_add(c)
|
|
c.post("/api/cameras/cam1/commission", json={"seconds": 600})
|
|
assert c.delete("/api/cameras/cam1/commission").status_code == 200
|
|
assert eng.workers["cam1"].commission.report()["cancelled"] is True
|
|
|
|
|
|
def test_tuning_round_trips_through_add_and_edit(client):
|
|
"""model_copy(update=...) does not coerce, so a `tuning` dict arriving as
|
|
JSON used to be stored raw and then fail the first time the camera asked
|
|
it for thresholds. It must come back as a real CameraTuning."""
|
|
from behavision.config import CameraTuning
|
|
|
|
c, eng = client
|
|
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
|
|
assert c.get("/api/cameras").json()[0]["tuning"]["min_enroll_quality"] == 0.40
|
|
|
|
r = c.patch("/api/cameras/overhead",
|
|
json={"tuning": {"min_enroll_quality": 0.35}})
|
|
assert r.status_code == 200
|
|
stored = eng.camera_store.get("overhead")
|
|
assert isinstance(stored.tuning, CameraTuning)
|
|
assert stored.tuning.min_enroll_quality == 0.35
|
|
|
|
|
|
def test_an_inverted_per_camera_pair_is_rejected_not_stored(client):
|
|
"""enroll >= match would make one camera contradict every other one, and
|
|
they all write into the same gallery."""
|
|
c, _ = client
|
|
r = _add(c, cid="bad",
|
|
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
|
|
assert r.status_code == 400
|
|
assert c.get("/api/cameras").json() == []
|