Find the camera on the network instead of asking for its address

The add-camera form asked for an IP address, and a shop owner does not
know their camera's IP address - it is on a sticker under the camera or
in a menu that differs by make. That field is where onboarding stopped
for anyone who was not an installer.

behavision/discover.py: one ONVIF WS-Discovery multicast (names the
camera and often its make) merged with a TCP sweep of port 554 across
the local /24 (misses nothing that streams). Stdlib only, ~4 s on the
office network, both cameras found. The add-camera sheet leads with
'Find cameras on this network'; picking a row fills the address and,
when the make is recognisable, the stream path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-21 12:27:47 +05:30
parent f88d441bbf
commit 1607f4ce74
13 changed files with 388 additions and 43 deletions

View File

@@ -32,6 +32,7 @@ export const api = {
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
discoverCameras: () => call('DiscoverCameras'),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),

View File

@@ -47,6 +47,11 @@ if (scenario) {
StartEngine: () => delay({state: 'running', reachable: true}),
StopEngine: () => delay({state: 'stopped', reachable: false}),
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
]}, 2500),
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },

View File

@@ -598,3 +598,15 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
/* Camera finder: the pick-list that replaces "type an IP address". */
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
.foundlist li button:hover { border-color: var(--accent); }
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
.foundlist .what { flex: 1; color: var(--ink-2); }
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }

View File

@@ -165,6 +165,24 @@ function CameraSheet({ cam, onClose, onSaved }) {
const chosen = makeById(make)
// The camera is picked from a scan of the shop's network rather than typed.
// Nobody knows their camera's address; the sticker is under the camera and
// the menu is different in every make's app. The scan names ONVIF cameras
// and lists anything with the RTSP port open; picking one fills the
// address and, when the make is recognisable, the stream path too.
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
async function findCameras() {
setScan('busy')
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
}
function pick(c) {
const m = c.make ? makeById(c.make) : null
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
id: prev.id || (m ? '' : ''), }))
if (m) setMake(m.id)
setTest(null)
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="sheet">
@@ -176,6 +194,40 @@ 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>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{isNew && (
<section className="formsection">
<h4>Find it</h4>
{scan === null && (
<div className="finder">
<p>Behavision can look for cameras on this shop’s network.</p>
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
</div>
)}
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
{scan?.cameras && (
scan.cameras.length === 0
? <div className="finder">
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
<button type="button" className="btn" onClick={findCameras}>Try again</button>
</div>
: <ul className="foundlist">
{scan.cameras.map(c => (
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
<button type="button" onClick={() => pick(c)}>
<span className="mono">{c.host}</span>
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
{f.host === c.host && <Icon.Check size={16} />}
</button>
</li>
))}
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
</ul>
)}
</section>
)}
<section className="formsection">
<h4>The camera</h4>
{isNew && (