Asked after the mobile-internet question: could a VPN let the office cameras
be shown in the demo. Three jobs get confused there and only one needs one.
Seeing the estate from anywhere already works and needs nothing - viewer mode
plus LiveHub is exactly that, outbound, no installation and no credential.
Demonstrating recognition is better done on the demo machine's own camera.
`webcam: 0` picks a capture index instead of building an RTSP URL and the
engine has supported it since the first version: CameraStore round-trips it,
source() returns the index, safe_url() reports webcam:0, and
POST /api/cameras {"id":"laptop","webcam":0} has always worked. No screen
offered it - the same gap this repo already records for the customer record
and per-camera tuning. It is now an option in the make picker, and it is the
strongest demo available: real faces, in the room, depending on no network.
A demo pointed at a camera in another building depends on two internet
connections and a tunnel staying up while somebody is talking.
The address and the index are alternatives, not extras: source() takes the
webcam first, so a half-typed host left behind would make the saved camera
describe two things and use one. The scan, the path and the camera password
are hidden for a local camera because none of them mean anything.
A tunnel is still right for one case - running the engine on a remote machine
against the office's own cameras - and still wrong for the product: it is
per-site infrastructure on every shop PC, and it gives head office
network-level access into a customer's LAN, where today we can read a
camera's picture and nothing else.
Also: installing httpx took the suite from 239 passed to 271. The HTTP tests
importorskip it so a bare checkout runs, which means the number at the bottom
of a run is not the number of tests that exist. Added to the dev extra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
267 lines
9.5 KiB
Python
267 lines
9.5 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
|
|
def latest_jpeg_since(self, known_ts): return None, known_ts
|
|
|
|
|
|
@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() == []
|
|
|
|
|
|
# "This computer's own camera" — the demo case, and a capability the engine
|
|
# has always had with nothing able to reach it.
|
|
#
|
|
# It matters most for showing the product to somebody. A laptop's own camera
|
|
# gives real recognition, of real faces, in the room, depending on no network
|
|
# at all — where pointing a demo machine at a camera in another building
|
|
# depends on two internet connections and a tunnel staying up while you talk.
|
|
def test_this_computers_own_camera_can_be_added(client):
|
|
c, eng = client
|
|
r = c.post("/api/cameras", json={"id": "laptop", "webcam": 0})
|
|
assert r.status_code == 201, r.text
|
|
assert "laptop" in eng.workers
|
|
|
|
cam = next(x for x in c.get("/api/cameras").json() if x["id"] == "laptop")
|
|
# Returned, or the form cannot tell a webcam camera from a half-filled
|
|
# RTSP one when somebody opens it to edit.
|
|
assert cam["webcam"] == 0
|
|
assert eng.camera_store.get("laptop").source() == 0
|
|
|
|
|
|
def test_a_second_camera_index_is_kept(client):
|
|
"""0 is the built-in one; a plugged-in camera is usually 1. An index
|
|
silently coerced to 0 would open the wrong camera and look like the
|
|
setting had no effect."""
|
|
c, eng = client
|
|
assert c.post("/api/cameras", json={"id": "usb", "webcam": 1}).status_code == 201
|
|
assert eng.camera_store.get("usb").source() == 1
|
|
|
|
|
|
def test_a_webcam_camera_survives_a_reload(client, tmp_path):
|
|
"""It has to be on disk, not only in the running engine: a demo that
|
|
forgets its camera when the app restarts is worse than no demo."""
|
|
c, _ = client
|
|
c.post("/api/cameras", json={"id": "laptop", "webcam": 0})
|
|
again = CameraStore(tmp_path / "cameras.json").get("laptop")
|
|
assert again.source() == 0
|
|
assert again.safe_url() == "webcam:0"
|