Files
Behavision/behavision/cameras.py
Suriyakumarvijayanayagam c7024b57ca The shipped config could not load on a machine without a .env
config/default.yaml reads its camera's host, username and password from
${ENV}. Unset placeholders parse as YAML null, and CameraConfig had no
_normalize_blanks - the guard ApiSection and EmailSection have had all
along - so loading it raised three pydantic errors and the engine would
not start at all. On a developer's checkout .env is right there, which is
why this survived: the failing machine is every machine the product is
actually installed on, and installer/build.ps1 runs this suite, so the
Windows build would have failed on a fresh clone.

The normalisation is field-by-field, never a blanket None -> "": `webcam`
is an Optional[int] whose None means "this is not a webcam", and `tuning`
is a nested model. Sweeping either trades one validation error for
another - which it did, on the first attempt.

Second bug behind the same line: that camera entry would then have been
SEEDED into a fresh install, giving a shop a camera called cam1 that
nobody added, retrying a connection to "" forever, with the first task on
a new PC being to work out what it was. CameraConfig.addressed() says
what a camera entry needs to be one, and seed() drops the rest.

Found by cloning the repository into a temp directory and running the
tests there. Nothing in a working tree can find this class of bug.

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

204 lines
7.9 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.
Entries with no address are dropped rather than imported. The bundled
config declares one whose host comes from the environment, which is
right for a checkout with a .env and resolves to nothing on every
machine the product is actually installed on. Seeding that would put a
camera the shop never added into a brand new install, permanently
failing to connect to "" — and the first thing they would have to do is
work out what it was and delete it.
"""
with self._lock:
usable = [c for c in cameras if c.addressed()]
if self.path.exists() or not usable:
return False
for cam in usable:
self._cameras[cam.id] = cam
self._save()
log.info("seeded %d camera(s) from YAML into %s",
len(usable), self.path)
return True