Compare commits
3 Commits
v0.5.7-dem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a2c392d9 | |||
| b296e8a74a | |||
| c9be9b5807 |
103
CLAUDE.md
103
CLAUDE.md
@@ -3699,3 +3699,106 @@ python.org / pyenv build OpenSSL 3.5.8 0 CAs -> reproduces it
|
|||||||
|
|
||||||
Checked for teeth by putting the shipped `except` back: both the corrected stub
|
Checked for teeth by putting the shipped `except` back: both the corrected stub
|
||||||
test and the live one fail, and pass again when it is restored.
|
test and the live one fail, and pass again when it is restored.
|
||||||
|
|
||||||
|
## "The cameras won't connect from my mobile internet" — and why that is not a bug
|
||||||
|
|
||||||
|
Asked directly by the owner, about his own cameras, from his phone's
|
||||||
|
connection. The answer is physics, and the product was not giving it.
|
||||||
|
|
||||||
|
A camera lives on the shop's LAN behind a router. `192.168.1.121` means
|
||||||
|
*"something on the network I am attached to"* and nothing more — from mobile
|
||||||
|
data, a hotel, or head office it resolves to nobody, or to a completely
|
||||||
|
different device that happens to hold that number. There is no route in from
|
||||||
|
the internet and **there must not be**: an RTSP camera reachable from outside
|
||||||
|
is how a shop's cameras end up being watched by strangers.
|
||||||
|
|
||||||
|
This is the reason the product is split the way it is, and it is worth stating
|
||||||
|
plainly next to the split itself: the shop PC is the only machine on the
|
||||||
|
camera's LAN, so it does the connecting, and every other surface reaches it
|
||||||
|
**outbound** — the agent's pull, the arrivals feed, and `LiveHub`'s frame relay,
|
||||||
|
which is what makes **Watch live** work from anywhere while nothing connects in.
|
||||||
|
|
||||||
|
What was wrong is the message. `cannot reach 192.168.1.121:554 - Operation
|
||||||
|
timed out` reads as a broken camera and sends somebody to re-type an address
|
||||||
|
and a password that were always correct. `_wrong_network_hint` now names the
|
||||||
|
cause, and it distinguishes two states that need opposite actions — the same
|
||||||
|
rule as `artifact` against `no_faces`, and `stale` against `not_connecting`:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| this computer **is** on that network | check the camera is powered on and that the address is right |
|
||||||
|
| this computer is **somewhere else** | the computer is in the wrong place; no setting here fixes it, recognition has to run on a machine in the shop |
|
||||||
|
|
||||||
|
- **The local address comes from a `connect`ed UDP socket** that sends nothing.
|
||||||
|
It only fixes a route so the kernel will name the source address — no packet
|
||||||
|
leaves, and it needs no dependency in an engine that already ships 200 MB of
|
||||||
|
models.
|
||||||
|
- **The LAN ranges are spelled out, not `is_private`.** That property is
|
||||||
|
broader than "an address on somebody's LAN": it also covers carrier-grade NAT
|
||||||
|
and the documentation networks (192.0.2, 198.51.100, 203.0.113), and telling
|
||||||
|
somebody who typed one of those that it is "on the shop's own network" is a
|
||||||
|
confident wrong answer in the place people look first. Found by a test using
|
||||||
|
`203.0.113.9` as its example of a *public* address, which `is_private` calls
|
||||||
|
private.
|
||||||
|
- **A DNS name gets no hint at all.** Nothing can be concluded about
|
||||||
|
`camera.local` from the string, and guessing is the failure mode this whole
|
||||||
|
message exists to fix.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -68,6 +68,78 @@ os.environ.setdefault(
|
|||||||
STALL_AFTER_S = 10.0
|
STALL_AFTER_S = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ipv4() -> str:
|
||||||
|
"""This machine's address on the interface holding the default route.
|
||||||
|
|
||||||
|
A UDP socket is `connect`ed and nothing is sent - it only fixes a route so
|
||||||
|
the kernel will name the source address. No packet leaves, and it needs no
|
||||||
|
dependency, which matters in an engine that already ships 200 MB of models.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.settimeout(0.5)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _wrong_network_hint(host: str) -> str:
|
||||||
|
"""Why a private camera address is unreachable, when that is the reason.
|
||||||
|
|
||||||
|
A camera lives on the shop's LAN behind a router, and 192.168.x.x means
|
||||||
|
"something on the network I am attached to" - nothing more. From mobile
|
||||||
|
data, a hotel, or head office it either resolves to nobody or to a
|
||||||
|
completely different device that happens to hold that number. There is no
|
||||||
|
route in from the internet and there must not be: an RTSP camera reachable
|
||||||
|
from outside is how a shop's cameras end up being watched by strangers.
|
||||||
|
|
||||||
|
Without this the answer was "cannot reach 192.168.1.121:554 - Operation
|
||||||
|
timed out", which reads as a broken camera and sends somebody to re-type
|
||||||
|
an address and a password that were always correct. Asked directly by the
|
||||||
|
owner, about his own cameras, from his phone's connection.
|
||||||
|
|
||||||
|
Two states, two different actions, so they must not share a sentence: on
|
||||||
|
the same network the camera or its address is the problem; on a different
|
||||||
|
one the COMPUTER is in the wrong place and no setting will fix it.
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return "" # a DNS name; nothing can be concluded from the string
|
||||||
|
# The RFC1918 blocks and link-local, spelled out rather than `is_private`.
|
||||||
|
# That property is broader than "an address on somebody's LAN": it also
|
||||||
|
# covers the carrier-grade NAT range and the documentation networks
|
||||||
|
# (192.0.2, 198.51.100, 203.0.113), and telling somebody who typed one of
|
||||||
|
# those that it is "on the shop's own network" would be a confident wrong
|
||||||
|
# answer in the place people look first. Found by a test using 203.0.113.9
|
||||||
|
# as an example of a PUBLIC address, which `is_private` calls private.
|
||||||
|
lan = any(addr in ipaddress.ip_network(n) for n in
|
||||||
|
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16")
|
||||||
|
if addr.version == 4)
|
||||||
|
if not lan:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
mine = _local_ipv4()
|
||||||
|
if not mine:
|
||||||
|
return (" - that is a private address, reachable only from inside "
|
||||||
|
"the network the camera is on")
|
||||||
|
try:
|
||||||
|
same = ipaddress.ip_network(f"{mine}/24", strict=False).supernet_of(
|
||||||
|
ipaddress.ip_network(f"{host}/24", strict=False))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
same = False
|
||||||
|
if same:
|
||||||
|
return (f" - this computer is on that network ({mine}), so check the "
|
||||||
|
f"camera is powered on and that {host} is its address")
|
||||||
|
return (f" - this computer is on {mine}, not the camera's network. A "
|
||||||
|
f"private address like {host} is only reachable from inside the "
|
||||||
|
f"shop's own network, never over the internet or mobile data, so "
|
||||||
|
f"recognition has to run on a computer in the shop")
|
||||||
|
|
||||||
|
|
||||||
def _tcp_reachable(source: "str | int", timeout: float
|
def _tcp_reachable(source: "str | int", timeout: float
|
||||||
) -> "tuple[bool, str]":
|
) -> "tuple[bool, str]":
|
||||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||||
@@ -85,10 +157,11 @@ def _tcp_reachable(source: "str | int", timeout: float
|
|||||||
return True, ""
|
return True, ""
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
return False, (f"no response from {parsed.hostname}:{port} within "
|
return False, (f"no response from {parsed.hostname}:{port} within "
|
||||||
f"{timeout:.0f}s - check the IP address and that the "
|
f"{timeout:.0f}s{_wrong_network_hint(parsed.hostname)}")
|
||||||
f"camera is on the same network")
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
return False, (f"cannot reach {parsed.hostname}:{port} - "
|
||||||
|
f"{exc.strerror or exc}"
|
||||||
|
f"{_wrong_network_hint(parsed.hostname)}")
|
||||||
|
|
||||||
|
|
||||||
def _fourcc(cap) -> str:
|
def _fourcc(cap) -> str:
|
||||||
|
|||||||
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-p8f6baZq.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-p8f6baZq.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-p8f6baZq.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { api, message } from '../bridge.js'
|
import { api, message } from '../bridge.js'
|
||||||
import { usePolled } from '../hooks.js'
|
import { usePolled } from '../hooks.js'
|
||||||
import * as Icon from '../ui/icons.jsx'
|
import * as Icon from '../ui/icons.jsx'
|
||||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
import { MAKES, makeById, parseRtspUrl } from '../../../../shared/cameraMakes.js'
|
||||||
|
|
||||||
// The camera screen is a picture, not a settings table.
|
// The camera screen is a picture, not a settings table.
|
||||||
//
|
//
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +253,27 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
|
|
||||||
const chosen = makeById(make)
|
const chosen = makeById(make)
|
||||||
|
|
||||||
|
// Paste the whole RTSP URL. It is how people actually hold this
|
||||||
|
// information - it is what the camera's own app shows and what an installer
|
||||||
|
// writes down - and splitting it into five fields by eye is exactly where a
|
||||||
|
// password containing `@` or `/` goes wrong.
|
||||||
|
const [pasted, setPasted] = useState('')
|
||||||
|
const [pasteError, setPasteError] = useState('')
|
||||||
|
function applyUrl(text) {
|
||||||
|
setPasted(text)
|
||||||
|
if (!text.trim()) { setPasteError(''); return }
|
||||||
|
const got = parseRtspUrl(text)
|
||||||
|
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||||
|
setPasteError('')
|
||||||
|
setTest(null)
|
||||||
|
// Only what the URL actually carried: a URL with no credentials must not
|
||||||
|
// wipe a password the operator typed above it.
|
||||||
|
setF(prev => ({ ...prev, host: got.host, port: got.port, path: got.path,
|
||||||
|
...(got.username ? { username: got.username } : {}),
|
||||||
|
...(got.password ? { password: got.password } : {}) }))
|
||||||
|
setMake('manual')
|
||||||
|
}
|
||||||
|
|
||||||
// The camera is picked from a scan of the shop's network rather than typed.
|
// 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
|
// 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
|
// the menu is different in every make's app. The scan names ONVIF cameras
|
||||||
@@ -259,7 +303,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 && (
|
||||||
@@ -293,6 +337,20 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isNew && !local && (
|
||||||
|
<section className="formsection">
|
||||||
|
<h4>Paste its address</h4>
|
||||||
|
<label className="field"><span>RTSP address</span>
|
||||||
|
<input className="mono" value={pasted} onChange={e => applyUrl(e.target.value)}
|
||||||
|
placeholder="rtsp://admin:password@192.168.1.20:554/ch0_0.264"
|
||||||
|
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||||
|
<em className="hint">{pasteError
|
||||||
|
? pasteError
|
||||||
|
: 'If the camera’s own app shows an RTSP address, paste it here and the fields below fill in. Otherwise leave this empty and fill them in yourself.'}</em>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="formsection">
|
<section className="formsection">
|
||||||
<h4>The camera</h4>
|
<h4>The camera</h4>
|
||||||
{isNew && (
|
{isNew && (
|
||||||
@@ -301,15 +359,20 @@ 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"><span>Address</span>
|
? <label className="field narrow"><span>Camera number</span>
|
||||||
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
<input value={index} onChange={e => { setIndex(e.target.value); setTest(null) }} inputMode="numeric" />
|
||||||
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
<em className="hint">0 is the built-in camera. Try 1 if a second one is plugged in.</em>
|
||||||
</label>
|
</label>
|
||||||
<label className="field narrow"><span>Port</span>
|
: <div className="fieldrow">
|
||||||
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
<label className="field"><span>Address</span>
|
||||||
</label>
|
<input value={f.host} onChange={set('host')} placeholder="192.168.1.20" inputMode="decimal" />
|
||||||
</div>
|
<em className="hint">On a label on the camera, or in its own app under “network”.</em>
|
||||||
|
</label>
|
||||||
|
<label className="field narrow"><span>Port</span>
|
||||||
|
<input value={f.port} onChange={set('port')} inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="formsection">
|
<section className="formsection">
|
||||||
@@ -317,27 +380,34 @@ 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
|
||||||
</label>
|
? <em className="hint">Recognition runs on this computer’s built-in or plugged-in camera. Nothing on the network is involved.</em>
|
||||||
<label className="field"><span>Stream path</span>
|
: chosen.note && <em className="hint">{chosen.note}</em>}
|
||||||
<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>
|
|
||||||
</label>
|
</label>
|
||||||
|
{!local && (
|
||||||
|
<label className="field"><span>Stream path</span>
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="formsection">
|
{!local && (
|
||||||
<h4>Sign-in to the camera</h4>
|
<section className="formsection">
|
||||||
<div className="fieldrow">
|
<h4>Sign-in to the camera</h4>
|
||||||
<label className="field"><span>Username</span>
|
<div className="fieldrow">
|
||||||
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
|
<label className="field"><span>Username</span>
|
||||||
</label>
|
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
|
||||||
<label className="field"><span>Password</span>
|
</label>
|
||||||
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
|
<label className="field"><span>Password</span>
|
||||||
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
|
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
|
||||||
</label>
|
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||||
</div>
|
</label>
|
||||||
</section>
|
</div>
|
||||||
|
</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*"]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>Behavision</title>
|
<title>Behavision</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BuVkBNdU.js"></script>
|
<script type="module" crossorigin src="/assets/index-BAlXnnln.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -79,3 +79,48 @@ export const MAKES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const makeById = (id) => MAKES.find(m => m.id === id) || MAKES[MAKES.length - 1]
|
export const makeById = (id) => MAKES.find(m => m.id === id) || MAKES[MAKES.length - 1]
|
||||||
|
|
||||||
|
// Paste the camera's RTSP URL, rather than taking it apart by hand.
|
||||||
|
//
|
||||||
|
// The engine has always accepted a whole URL (`CameraConfig.url` wins over the
|
||||||
|
// parts) and no form has ever offered one - the same gap as the webcam option,
|
||||||
|
// and it costs more here. A URL is how people actually HAVE this information:
|
||||||
|
// it is what the camera's own app shows, what an installer writes down and
|
||||||
|
// what gets pasted into a message. Splitting it into five fields by eye is
|
||||||
|
// where a password containing `@` or `/` goes wrong, and this repository
|
||||||
|
// already records a whole class of bug from unencoded `@` in RTSP credentials.
|
||||||
|
//
|
||||||
|
// Split into fields rather than stored whole, deliberately: everything else on
|
||||||
|
// the form - Test, the make picker, editing later, and the rule that a
|
||||||
|
// password is never returned to the browser - works on the parts. A URL kept
|
||||||
|
// intact would carry the password back out to every screen that reads a
|
||||||
|
// camera.
|
||||||
|
export function parseRtspUrl(raw) {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
|
||||||
|
// A bare `host/path` is a reasonable thing to paste, so the scheme is
|
||||||
|
// optional - but something has to mark this as a URL rather than a word.
|
||||||
|
// Without the slash test, `nonsense` parses as a perfectly good hostname
|
||||||
|
// and silently fills the Address field with it: a wrong answer that looks
|
||||||
|
// like it worked, which is worse than refusing.
|
||||||
|
if (!hasScheme && !text.includes('/')) return null
|
||||||
|
const withScheme = hasScheme ? text : 'rtsp://' + text
|
||||||
|
let u
|
||||||
|
try {
|
||||||
|
u = new URL(withScheme)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!u.hostname) return null
|
||||||
|
// WHATWG splits user info at the LAST `@`, which is what makes an unencoded
|
||||||
|
// `@` inside a password parse the way a person means it.
|
||||||
|
const out = {
|
||||||
|
host: u.hostname,
|
||||||
|
port: Number(u.port) || 554,
|
||||||
|
path: (u.pathname || '') + (u.search || '') || '/',
|
||||||
|
username: decodeURIComponent(u.username || ''),
|
||||||
|
password: decodeURIComponent(u.password || ''),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
91
tests/test_rtsp_paste.py
Normal file
91
tests/test_rtsp_paste.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""`shared/cameraMakes.js` is imported by BOTH camera forms and has no test
|
||||||
|
runner of its own. This is the same safety net `test_dashboard.py` provides:
|
||||||
|
node is driven from pytest and the check is skipped when node is absent, so
|
||||||
|
the suite stays dependency-light.
|
||||||
|
|
||||||
|
What it guards is the field people actually have. The engine has always
|
||||||
|
accepted a whole RTSP URL (`CameraConfig.url` wins over the parts) and no form
|
||||||
|
ever offered one, so an operator holding the address their camera's own app
|
||||||
|
shows had to take it apart into five fields by eye — which is exactly where a
|
||||||
|
password containing `@` goes wrong, a class of bug this repository has already
|
||||||
|
been bitten by once.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SHARED = ROOT / "shared" / "cameraMakes.js"
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(shutil.which("node") is None,
|
||||||
|
reason="node not installed")
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text):
|
||||||
|
out = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e",
|
||||||
|
f"import {{ parseRtspUrl }} from {json.dumps(str(SHARED))};"
|
||||||
|
f"console.log(JSON.stringify(parseRtspUrl({json.dumps(text)})))"],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
return json.loads(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_url_becomes_the_five_fields():
|
||||||
|
assert parse("rtsp://admin:Pass123@192.168.1.121:554/ch0_0.264") == {
|
||||||
|
"host": "192.168.1.121", "port": 554, "path": "/ch0_0.264",
|
||||||
|
"username": "admin", "password": "Pass123"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_at_sign_in_the_password_survives():
|
||||||
|
"""The one that matters. A URL is split at the LAST `@`, which is what
|
||||||
|
makes an unencoded `@` inside a password parse the way a person means it -
|
||||||
|
and an operator splitting this by eye would put `p` in the password box
|
||||||
|
and `ssw0rd@192.168.1.121` in the address box."""
|
||||||
|
got = parse("rtsp://admin:p@ssw0rd@192.168.1.121:554/Streaming/Channels/101")
|
||||||
|
assert got["password"] == "p@ssw0rd"
|
||||||
|
assert got["host"] == "192.168.1.121"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_percent_encoded_password_is_decoded():
|
||||||
|
"""Stored decoded, because CameraConfig.source() percent-encodes when it
|
||||||
|
rebuilds the URL. Keeping it encoded would double-encode it and the camera
|
||||||
|
would refuse a password that is correct."""
|
||||||
|
assert parse("rtsp://admin:p%40ss@10.0.0.5/live")["password"] == "p@ss"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_port_is_filled_in():
|
||||||
|
assert parse("rtsp://192.168.1.122/ch0_1.264")["port"] == 554
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_query_string_stays_with_the_path():
|
||||||
|
"""Some cameras carry the channel in a query. Dropping it opens the wrong
|
||||||
|
channel, which looks like a camera pointed somewhere unexpected."""
|
||||||
|
assert parse("rtsp://cam.local:8554/live?channel=1")["path"] == "/live?channel=1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scheme_is_optional_but_structure_is_not():
|
||||||
|
assert parse("192.168.1.122/ch0_1.264")["host"] == "192.168.1.122"
|
||||||
|
# A bare word parses as a perfectly good hostname, so without this it
|
||||||
|
# would silently fill the Address field with it - a wrong answer that
|
||||||
|
# looks like it worked, which is worse than refusing.
|
||||||
|
assert parse("nonsense") is None
|
||||||
|
assert parse("192.168.1.121") is None, "a bare address is not a URL; the Address field takes it"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_nothing():
|
||||||
|
assert parse("") is None
|
||||||
|
assert parse(" ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_forms_import_it():
|
||||||
|
"""Two copies of this would be worse than not offering it, because an
|
||||||
|
operator trusts a filled-in field. Same rule as the make picker."""
|
||||||
|
for form in (ROOT / "desktop/frontend/src/views/Cameras.jsx",
|
||||||
|
ROOT / "web/src/views/CameraSetup.jsx"):
|
||||||
|
src = form.read_text(encoding="utf-8")
|
||||||
|
assert "parseRtspUrl" in src, f"{form.name} does not offer the paste field"
|
||||||
|
assert "cameraMakes.js" in src, f"{form.name} defines its own parser"
|
||||||
81
tests/test_wrong_network.py
Normal file
81
tests/test_wrong_network.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Why a camera "will not connect", when the real answer is that the computer
|
||||||
|
asking is in the wrong building.
|
||||||
|
|
||||||
|
Asked directly by the owner, about his own cameras, from his phone's
|
||||||
|
connection: *"when i connect from my mobile internet the cameras wont connect,
|
||||||
|
why is that"*. The answer was `cannot reach 192.168.1.121:554 - Operation
|
||||||
|
timed out`, which reads as a broken camera and sends somebody to re-type an
|
||||||
|
address and a password that were always correct.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from behavision import capture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def on(monkeypatch):
|
||||||
|
def _set(ip):
|
||||||
|
monkeypatch.setattr(capture, "_local_ipv4", lambda: ip)
|
||||||
|
return _set
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_different_network_says_so_and_says_what_to_do(on):
|
||||||
|
on("10.11.12.13") # a phone's tethered network
|
||||||
|
hint = capture._wrong_network_hint("192.168.1.121")
|
||||||
|
assert "not the camera's network" in hint
|
||||||
|
# The action, not just the diagnosis: no setting on this screen fixes it.
|
||||||
|
assert "computer in the shop" in hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_network_sends_you_to_the_camera_instead(on):
|
||||||
|
on("192.168.1.120")
|
||||||
|
hint = capture._wrong_network_hint("192.168.1.121")
|
||||||
|
assert "on that network" in hint
|
||||||
|
assert "powered on" in hint
|
||||||
|
# Two states that need opposite actions must not share a sentence.
|
||||||
|
assert "not the camera's network" not in hint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", [
|
||||||
|
"8.8.8.8", # plainly routable
|
||||||
|
"203.0.113.9", # TEST-NET-3: `is_private` calls this private, and it is
|
||||||
|
# not a LAN address - which is why the check spells out
|
||||||
|
# the RFC1918 blocks instead of asking is_private
|
||||||
|
"100.64.0.5", # carrier-grade NAT, what a mobile network hands out
|
||||||
|
])
|
||||||
|
def test_an_address_that_is_not_a_lan_address_gets_no_hint(on, host):
|
||||||
|
"""A routable address unreachable from here is an ordinary network fault,
|
||||||
|
and inventing a story about private networks would be wrong."""
|
||||||
|
on("192.168.1.120")
|
||||||
|
assert capture._wrong_network_hint(host) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_gets_no_hint(on):
|
||||||
|
"""Nothing can be concluded about `camera.local` from the string, and a
|
||||||
|
guess here is a confident wrong answer in the place people look first."""
|
||||||
|
on("192.168.1.120")
|
||||||
|
assert capture._wrong_network_hint("camera.local") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_loopback_gets_no_hint(on):
|
||||||
|
on("192.168.1.120")
|
||||||
|
assert capture._wrong_network_hint("127.0.0.1") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_no_network_at_all_it_still_names_the_cause(on):
|
||||||
|
"""A machine with no route cannot say which network it is on, and must not
|
||||||
|
pretend: the private-address fact is still true and still the reason."""
|
||||||
|
on("")
|
||||||
|
hint = capture._wrong_network_hint("192.168.1.121")
|
||||||
|
assert "private address" in hint
|
||||||
|
assert "this computer is on" not in hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hint_reaches_the_message_a_person_reads():
|
||||||
|
"""The whole point is the sentence on the screen, not a helper nobody
|
||||||
|
calls. Port 1 on a private address refuses or times out immediately."""
|
||||||
|
ok, msg = capture._tcp_reachable("rtsp://192.168.1.121:1/ch0", 1.0)
|
||||||
|
assert not ok
|
||||||
|
assert "192.168.1.121" in msg
|
||||||
|
# Whichever branch the OS takes, the explanation travels with it.
|
||||||
|
assert "network" in msg
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { MAKES, makeById } from '../../../shared/cameraMakes.js'
|
import { MAKES, makeById, parseRtspUrl } from '../../../shared/cameraMakes.js'
|
||||||
|
|
||||||
// Setting up a camera, for somebody who has never done it.
|
// Setting up a camera, for somebody who has never done it.
|
||||||
//
|
//
|
||||||
@@ -30,6 +30,25 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
||||||
|
|
||||||
|
// Paste the whole RTSP address. It is how people actually hold this
|
||||||
|
// information - it is what the camera's own app shows and what an installer
|
||||||
|
// writes down - and splitting it into five fields by eye is where a
|
||||||
|
// password containing `@` or `/` goes wrong.
|
||||||
|
const [pasted, setPasted] = useState('')
|
||||||
|
const [pasteError, setPasteError] = useState('')
|
||||||
|
const applyUrl = (text) => {
|
||||||
|
setPasted(text)
|
||||||
|
if (!text.trim()) { setPasteError(''); return }
|
||||||
|
const got = parseRtspUrl(text)
|
||||||
|
if (!got) { setPasteError('That does not look like an RTSP address.'); return }
|
||||||
|
setPasteError('')
|
||||||
|
// Only what the URL actually carried: one with no credentials must not
|
||||||
|
// wipe a password already typed.
|
||||||
|
setForm(f => ({ ...f, make: 'manual', host: got.host, port: got.port, path: got.path,
|
||||||
|
...(got.username ? { username: got.username } : {}),
|
||||||
|
...(got.password ? { password: got.password } : {}) }))
|
||||||
|
}
|
||||||
const chooseMake = (e) => {
|
const chooseMake = (e) => {
|
||||||
const m = makeById(e.target.value)
|
const m = makeById(e.target.value)
|
||||||
// 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
|
||||||
@@ -115,6 +134,14 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
|||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="drawer-body">
|
<div className="drawer-body">
|
||||||
|
<label>Paste the camera’s RTSP address, if you have one
|
||||||
|
<input className="mono" value={pasted} onChange={(e) => applyUrl(e.target.value)}
|
||||||
|
placeholder="rtsp://admin:password@192.168.0.138:554/ch0_0.264"
|
||||||
|
autoComplete="off" name="rtsp-url" spellCheck="false" />
|
||||||
|
<span className="hint">{pasteError
|
||||||
|
? pasteError
|
||||||
|
: 'Optional. Paste it and the fields below fill in; otherwise fill them in yourself.'}</span>
|
||||||
|
</label>
|
||||||
<label>Camera’s address on the shop’s network
|
<label>Camera’s address on the shop’s network
|
||||||
<input value={form.host} onChange={set('host')}
|
<input value={form.host} onChange={set('host')}
|
||||||
placeholder="192.168.0.138" autoFocus />
|
placeholder="192.168.0.138" autoFocus />
|
||||||
|
|||||||
Reference in New Issue
Block a user