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:
@@ -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),
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user