3 Commits

Author SHA1 Message Date
c6a2c392d9 Paste the camera's RTSP address instead of taking it apart by hand
Asked directly: "our cameras have an rtsp url, we can use that to connect them
to this software right". Yes - and that has always been the mechanism, which
is the point. CameraConfig.source() builds exactly that URL from the parts,
and CameraConfig.url has always accepted a whole one and taken priority over
them. No form ever offered it.

So an operator holding the address their camera's own app shows had to split
it into five fields by eye. That is where a password containing @ or / goes
wrong, and this repository has already been bitten once by unencoded @ in RTSP
credentials.

parseRtspUrl lives in shared/cameraMakes.js and is imported by BOTH forms, for
the same reason the make picker is: two copies would be worse than not
offering it, because an operator trusts a filled-in field. A test asserts both
import it.

Decisions worth keeping:

- Split into fields, not stored whole. 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.
- WHATWG splits user info at the LAST @, which is what makes an unencoded @
  inside a password parse the way a person means it. An operator doing it by
  eye would put "p" in the password box and "ssw0rd@192.168.1.121" in the
  address box.
- Percent-encoded credentials are DECODED, because source() encodes again
  when it rebuilds the URL. Keeping them encoded would double-encode and the
  camera would refuse a password that is correct.
- The scheme is optional, structure is not. Without requiring a slash,
  "nonsense" parses as a perfectly good hostname and silently fills the
  Address field with it - a wrong answer that looks like it worked. A bare
  address is refused too: the Address field already takes one.
- A query string stays with the path. Some cameras carry the channel there,
  and dropping it opens the wrong channel - which looks like a camera pointed
  somewhere unexpected.
- A URL carrying no credentials does not wipe a password already typed.

Tested through node from pytest, the same pattern test_dashboard.py uses, and
skipped when node is absent so the suite stays dependency-light.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 18:29:59 +05:30
b296e8a74a 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
2026-09-30 18:18:57 +05:30
c9be9b5807 "The cameras won't connect from my mobile internet" was answered by a timeout
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 holding 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.

That is why the product is split the way it is - the shop PC is the only
machine on the camera's LAN, and every other surface reaches it outbound,
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 names the cause
and separates two states that need opposite actions:

  on that network   -> check the camera is powered on and the address is right
  somewhere else    -> the COMPUTER is in the wrong place; no setting fixes it

- The local address comes from a connected UDP socket that sends nothing. It
  only fixes a route so the kernel will name the source address.
- The LAN ranges are spelled out, not is_private. That property also covers
  carrier-grade NAT and the documentation networks, and telling somebody who
  typed 203.0.113.9 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 that
  address as its example of a PUBLIC one.
- A DNS name gets no hint: nothing can be concluded about camera.local from
  the string, and guessing is the failure mode this 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 18:00:44 +05:30
14 changed files with 620 additions and 87 deletions

103
CLAUDE.md
View File

@@ -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
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.

View File

@@ -68,6 +68,78 @@ os.environ.setdefault(
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
) -> "tuple[bool, str]":
"""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, ""
except socket.timeout:
return False, (f"no response from {parsed.hostname}:{port} within "
f"{timeout:.0f}s - check the IP address and that the "
f"camera is on the same network")
f"{timeout:.0f}s{_wrong_network_hint(parsed.hostname)}")
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:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import { usePolled } from '../hooks.js'
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.
//
@@ -14,6 +14,18 @@ import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
// signed off through.
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() {
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
const [editing, setEditing] = useState(null)
@@ -190,7 +202,9 @@ function useStreamURLs(cams) {
function CameraSheet({ cam, onClose, onSaved }) {
const isNew = !cam.id
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 [busy, setBusy] = 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
// path" does not wipe what the installer already typed.
function chooseMake(e) {
if (e.target.value === WEBCAM) { setMake(WEBCAM); setTest(null); return }
const m = makeById(e.target.value)
setMake(m.id)
setF(prev => ({ ...prev, path: m.path || prev.path }))
@@ -212,6 +227,14 @@ function CameraSheet({ cam, onClose, onSaved }) {
if (v === '' || v === null || v === undefined) continue
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
}
@@ -230,6 +253,27 @@ function CameraSheet({ cam, onClose, onSaved }) {
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.
// 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
@@ -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>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{isNew && (
{isNew && !local && (
<section className="formsection">
<h4>Find it</h4>
{scan === null && (
@@ -293,6 +337,20 @@ function CameraSheet({ cam, onClose, onSaved }) {
</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">
<h4>The camera</h4>
{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>
</label>
)}
<div className="fieldrow">
<label className="field"><span>Address</span>
<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>
</label>
<label className="field narrow"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric" />
</label>
</div>
{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>
<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>
</label>
<label className="field narrow"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric" />
</label>
</div>}
</section>
<section className="formsection">
@@ -317,27 +380,34 @@ function CameraSheet({ cam, onClose, onSaved }) {
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
<option value={WEBCAM}>This computer’s own camera</option>
</select>
{chosen.note && <em className="hint">{chosen.note}</em>}
</label>
<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>
{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>
{!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 className="formsection">
<h4>Sign-in to the camera</h4>
<div className="fieldrow">
<label className="field"><span>Username</span>
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
</label>
<label className="field"><span>Password</span>
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
</section>
{!local && (
<section className="formsection">
<h4>Sign-in to the camera</h4>
<div className="fieldrow">
<label className="field"><span>Username</span>
<input name="rtsp-account" autoComplete="off" value={f.username} onChange={set('username')} placeholder="admin" />
</label>
<label className="field"><span>Password</span>
<input type="password" name="rtsp-secret" autoComplete="new-password" value={f.password}
onChange={set('password')} placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
</section>
)}
{test && (
test.ok

View File

@@ -22,7 +22,11 @@ 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]
include = ["behavision*"]

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/favicon.png" />
<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">
</head>
<body>

View File

@@ -79,3 +79,48 @@ export const MAKES = [
]
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
}

View File

@@ -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})
assert r.status_code == 400
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
View 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"

View 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

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
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.
//
@@ -30,6 +30,25 @@ export default function CameraSetup({ sites, existing, onClose, onSaved }) {
const [error, setError] = useState('')
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 m = makeById(e.target.value)
// 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 && (
<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
<input value={form.host} onChange={set('host')}
placeholder="192.168.0.138" autoFocus />