A demo does not need a tunnel; it needs the laptop's own camera
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
This commit is contained in:
57
CLAUDE.md
57
CLAUDE.md
@@ -3745,3 +3745,60 @@ rule as `artifact` against `no_faces`, and `stale` against `not_connecting`:
|
|||||||
message exists to fix.
|
message exists to fix.
|
||||||
- **With no network at all it still names the cause** and drops the comparison,
|
- **With no network at all it still names the cause** and drops the comparison,
|
||||||
rather than claiming to know which network this machine is on.
|
rather than claiming to know which network this machine is on.
|
||||||
|
|
||||||
|
## A tunnel for the demo: when it is the right answer, and when it is not
|
||||||
|
|
||||||
|
Asked after the mobile-internet question: *"what if we created a secure tunnel
|
||||||
|
or vpn, then the cameras in our office can be shown in the demo version too?"*
|
||||||
|
|
||||||
|
Three different jobs get confused here, and only one of them needs a tunnel.
|
||||||
|
|
||||||
|
**Seeing the estate from anywhere already works and needs nothing.** Viewer
|
||||||
|
mode plus `LiveHub` is exactly this: the shop PC pushes frames outbound and any
|
||||||
|
signed-in app sees them, from mobile data, a hotel or a customer's office. A
|
||||||
|
VPN would add an installation, a credential and a moving part to something that
|
||||||
|
already works with none of them.
|
||||||
|
|
||||||
|
**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**, which is the same gap this file already records for the customer
|
||||||
|
record and for per-camera tuning: the API could, the UI could not reach it.
|
||||||
|
|
||||||
|
It is now an option in the make picker, and it is the strongest demo available:
|
||||||
|
real faces, in the room, instant, depending on no network at all. A demo
|
||||||
|
pointed at a camera in another building depends on two internet connections and
|
||||||
|
a tunnel staying up while somebody is talking.
|
||||||
|
|
||||||
|
**The one case a tunnel genuinely answers** is running the engine on a remote
|
||||||
|
machine against the office's own cameras — LAN access to `192.168.1.121` from
|
||||||
|
somewhere that is not that LAN. A mesh VPN (Tailscale and similar) does that
|
||||||
|
honestly: a subnet router at the office, the client on the demo machine, and
|
||||||
|
the address works unchanged. Free at this scale, no port forwarding, no
|
||||||
|
exposed camera. Worth using for our *own* office when that is really the goal.
|
||||||
|
|
||||||
|
**It is still the wrong answer for the product**, and the reasons are not about
|
||||||
|
difficulty:
|
||||||
|
|
||||||
|
- It is per-site infrastructure — an account, a node, a key — on every shop PC
|
||||||
|
we ship, to replace something that already works over ordinary HTTPS.
|
||||||
|
- It gives head office **network-level access into a customer's LAN**. The
|
||||||
|
current design can read a camera's picture; a VPN can reach the customer's
|
||||||
|
till, their router and everything else on that network. That is a far larger
|
||||||
|
thing to be trusted with, and a far larger thing to have breached.
|
||||||
|
- The failure modes are worse and less legible: a tunnel that is down looks
|
||||||
|
like a camera that is down.
|
||||||
|
|
||||||
|
So: tunnel for our own office if we want the engine running remotely; nothing
|
||||||
|
at all for showing customers their shops; the laptop's own camera for showing
|
||||||
|
anybody what the product does.
|
||||||
|
|
||||||
|
### And the suite was quietly 32 tests smaller than it looked
|
||||||
|
|
||||||
|
Installing `httpx` to check the webcam path took the engine suite from **239
|
||||||
|
passed to 271**. `tests/test_api_cameras.py` and its siblings begin with
|
||||||
|
`pytest.importorskip("httpx")` so a bare checkout still runs — deliberate, and
|
||||||
|
it means the number at the bottom of a run is not the number of tests that
|
||||||
|
exist. `pip install -e .[dev]` is the opt-in.
|
||||||
|
|||||||
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-DSNu_2FW.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-DSNu_2FW.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
desktop/frontend/dist/index.html
vendored
2
desktop/frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
<script type="module" crossorigin src="./assets/index-6LYbNlbD.js"></script>
|
<script type="module" crossorigin src="./assets/index-DSNu_2FW.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
|||||||
// signed off through.
|
// signed off through.
|
||||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
|
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '', max_width: 1280 }
|
||||||
|
|
||||||
|
// "This computer's own camera" - a webcam or a built-in FaceTime camera.
|
||||||
|
//
|
||||||
|
// The engine has supported it since the first version (`webcam: 0` picks a
|
||||||
|
// capture index instead of building an RTSP URL) and no screen has ever
|
||||||
|
// offered it: another case of the API being able to do something the UI
|
||||||
|
// could not reach. It matters most for the thing it was missing from, which
|
||||||
|
// is 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.
|
||||||
|
const WEBCAM = 'webcam'
|
||||||
|
|
||||||
export default function Cameras() {
|
export default function Cameras() {
|
||||||
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
@@ -190,7 +202,9 @@ function useStreamURLs(cams) {
|
|||||||
function CameraSheet({ cam, onClose, onSaved }) {
|
function CameraSheet({ cam, onClose, onSaved }) {
|
||||||
const isNew = !cam.id
|
const isNew = !cam.id
|
||||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
|
const [f, setF] = useState({ ...BLANK, ...cam, password: '', path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
const [make, setMake] = useState(isNew ? MAKES[0].id : (cam.webcam != null ? WEBCAM : 'manual'))
|
||||||
|
const [index, setIndex] = useState(cam.webcam != null ? String(cam.webcam) : '0')
|
||||||
|
const local = make === WEBCAM
|
||||||
const [test, setTest] = useState(null)
|
const [test, setTest] = useState(null)
|
||||||
const [busy, setBusy] = useState(null)
|
const [busy, setBusy] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -199,6 +213,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||||
// path" does not wipe what the installer already typed.
|
// path" does not wipe what the installer already typed.
|
||||||
function chooseMake(e) {
|
function chooseMake(e) {
|
||||||
|
if (e.target.value === WEBCAM) { setMake(WEBCAM); setTest(null); return }
|
||||||
const m = makeById(e.target.value)
|
const m = makeById(e.target.value)
|
||||||
setMake(m.id)
|
setMake(m.id)
|
||||||
setF(prev => ({ ...prev, path: m.path || prev.path }))
|
setF(prev => ({ ...prev, path: m.path || prev.path }))
|
||||||
@@ -212,6 +227,14 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
if (v === '' || v === null || v === undefined) continue
|
if (v === '' || v === null || v === undefined) continue
|
||||||
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
|
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
|
||||||
}
|
}
|
||||||
|
if (local) {
|
||||||
|
// An address and a webcam index are alternatives, not extras: the
|
||||||
|
// engine's source() takes the webcam first, so leaving a half-typed
|
||||||
|
// host behind would make the saved camera describe two different
|
||||||
|
// things and only one of them would be used.
|
||||||
|
for (const k of ['host', 'path', 'username', 'password']) delete out[k]
|
||||||
|
out.webcam = Number(index) || 0
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +282,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
|
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
|
||||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||||
|
|
||||||
{isNew && (
|
{isNew && !local && (
|
||||||
<section className="formsection">
|
<section className="formsection">
|
||||||
<h4>Find it</h4>
|
<h4>Find it</h4>
|
||||||
{scan === null && (
|
{scan === null && (
|
||||||
@@ -301,7 +324,12 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
<em className="hint">Short, no spaces. It names this camera everywhere and cannot be changed later.</em>
|
<em className="hint">Short, no spaces. It names this camera everywhere and cannot be changed later.</em>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="fieldrow">
|
{local
|
||||||
|
? <label className="field narrow"><span>Camera number</span>
|
||||||
|
<input value={index} onChange={e => { setIndex(e.target.value); setTest(null) }} inputMode="numeric" />
|
||||||
|
<em className="hint">0 is the built-in camera. Try 1 if a second one is plugged in.</em>
|
||||||
|
</label>
|
||||||
|
: <div className="fieldrow">
|
||||||
<label className="field"><span>Address</span>
|
<label className="field"><span>Address</span>
|
||||||
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
||||||
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
||||||
@@ -309,7 +337,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
<label className="field narrow"><span>Port</span>
|
<label className="field narrow"><span>Port</span>
|
||||||
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="formsection">
|
<section className="formsection">
|
||||||
@@ -317,15 +345,21 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
<label className="field"><span>Make of camera</span>
|
<label className="field"><span>Make of camera</span>
|
||||||
<select value={make} onChange={chooseMake}>
|
<select value={make} onChange={chooseMake}>
|
||||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
|
<option value={WEBCAM}>This computer’s own camera</option>
|
||||||
</select>
|
</select>
|
||||||
{chosen.note && <em className="hint">{chosen.note}</em>}
|
{local
|
||||||
|
? <em className="hint">Recognition runs on this computer’s built-in or plugged-in camera. Nothing on the network is involved.</em>
|
||||||
|
: chosen.note && <em className="hint">{chosen.note}</em>}
|
||||||
</label>
|
</label>
|
||||||
|
{!local && (
|
||||||
<label className="field"><span>Stream path</span>
|
<label className="field"><span>Stream path</span>
|
||||||
<input className="mono" value={f.path} onChange={set('path')} placeholder="/Streaming/Channels/101" />
|
<input className="mono" value={f.path} onChange={set('path')} placeholder="/Streaming/Channels/101" />
|
||||||
<em className="hint">Filled in from the make. Change it only if the camera’s own app says something else.</em>
|
<em className="hint">Filled in from the make. Change it only if the camera’s own app says something else.</em>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!local && (
|
||||||
<section className="formsection">
|
<section className="formsection">
|
||||||
<h4>Sign-in to the camera</h4>
|
<h4>Sign-in to the camera</h4>
|
||||||
<div className="fieldrow">
|
<div className="fieldrow">
|
||||||
@@ -338,6 +372,7 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{test && (
|
{test && (
|
||||||
test.ok
|
test.ok
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=8.0"]
|
# httpx is test-only and never ships in the wheel. The HTTP tests begin with
|
||||||
|
# `pytest.importorskip("httpx")` so a bare checkout still runs - which is
|
||||||
|
# right, and has a cost worth knowing: without it the suite reports 239 passed
|
||||||
|
# and quietly SKIPS 32 API tests. `pip install -e .[dev]` is how to get them.
|
||||||
|
dev = ["pytest>=8.0", "httpx>=0.27"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["behavision*"]
|
include = ["behavision*"]
|
||||||
|
|||||||
@@ -225,3 +225,42 @@ def test_an_inverted_per_camera_pair_is_rejected_not_stored(client):
|
|||||||
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
|
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert c.get("/api/cameras").json() == []
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user