Live camera view in the app, and the green light that was lying about it
Two changes, and the second was found by verifying the first. ## Watching a camera from the app, in another building Snapshots answer "is that camera working". They do not answer "what is happening in my shop right now", which is what somebody who opens the app away from the counter is asking. Head office's browser already had that answer - LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is watching and pushes JPEG frames for as long as somebody is - and the app could not reach it. cloud.CameraLive opens that feed and the app's own loopback relay re-emits it as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img> cannot render that, and an <img> renders MJPEG natively - so a tile is an ordinary <img> pointed at loopback whether the camera is in this room or another city. - Reconnecting happens in the relay, not the page. The server caps one push at five minutes, so doing it here means the <img> never sees the stream end. - The headers are flushed before the first frame. Go writes them on the first body write, so without that the whole response waits for the shop PC to start pushing. Measured against production: 30 seconds and not even a Content-Type, which surfaces as the request timing out. - One camera at a time. Watching makes a shop PC upload, so a grid that went live at once would put an estate's worth of cameras on the wire because somebody opened a page. - live.mjpeg is behind the same per-run token as the engine routes, and a wrong token is a 404 that never reaches head office at all. - CameraLive uses its own HTTP client: the shared one's 30s timeout covers the whole response and would sever a working view every thirty seconds - the trap that made the server set WriteTimeout to zero for its own SSE endpoint. ## A camera read "Connected" for 34 minutes after the shop PC went blind Which is why the verification above looked like a failure: head office registered the viewer and no frame ever came. reportWith returns early when the engine is unreachable - correctly, it has nothing to say - so the last state it sent stays in the database looking current. Measured live: cam2 and entrance both reading Connected, in green, with last_seen_at 34 minutes old, while the heartbeat from the same PC said cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing. false could not be the answer. It means "this camera is not connecting", which sends an installer to check cabling on a camera that was working perfectly the last time anybody could ask it. So there are four states and one function: connected reported recently, and working not_connecting reported recently, and the stream will not open waiting no shop PC has ever reported this camera stale reported once, and not lately - Connected is CLEARED when stale or waiting. A stale true left in place stays available to every client reading the field directly, and leaves two fields on one object disagreeing - how the shops screen once came out labelled Working, in green, above "2 of 3 cameras not connecting". - Computed in scanCamera, so every camera anybody reads passes through it. A state computed per handler is one a handler forgets, and this had already reached three screens. - CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning as three missed heartbeats - an indicator that cries wolf gets ignored. - An unparseable last_seen_at is stale. It should be impossible, which is why it must not fall through to the state that says everything is fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-6LYbNlbD.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-vAtlvw9l.js
vendored
40
desktop/frontend/dist/assets/index-vAtlvw9l.js
vendored
File diff suppressed because one or more lines are too long
4
desktop/frontend/dist/index.html
vendored
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<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-vAtlvw9l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-xWw5ie4A.css">
|
||||
<script type="module" crossorigin src="./assets/index-6LYbNlbD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,6 +38,9 @@ export const api = {
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
// The live view of a camera in another building, relayed through head
|
||||
// office. Empty when nobody is signed in.
|
||||
remoteStreamURL: (id) => call('RemoteStreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
|
||||
@@ -648,3 +648,18 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
}
|
||||
.viewing b { color: var(--ink); font-weight: 600; }
|
||||
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
|
||||
|
||||
/* Watch live sits over the picture, opposite the connection pill. It is on
|
||||
the tile rather than in the button row because it is about the picture, and
|
||||
because the row it would otherwise join is hidden on a remote camera. */
|
||||
.camview .btn.watch {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-color: rgba(255, 255, 255, .25);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
|
||||
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
@@ -27,6 +27,19 @@ export default function Cameras() {
|
||||
// than one that is absent.
|
||||
const remote = cams.some(c => c.remote)
|
||||
const streams = useStreamURLs(remote ? [] : cams)
|
||||
// ONE camera at a time, and that is a cost decision rather than a layout
|
||||
// one. A remote view makes the shop computer upload frames for as long as
|
||||
// somebody is watching, so a grid that all went live at once would put an
|
||||
// estate's worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
const [watchURL, setWatchURL] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (!watching) { setWatchURL(''); return }
|
||||
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
|
||||
.catch(() => { if (alive) setWatchURL('') })
|
||||
return () => { alive = false }
|
||||
}, [watching])
|
||||
|
||||
async function remove(cam) {
|
||||
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
|
||||
@@ -49,8 +62,9 @@ export default function Cameras() {
|
||||
|
||||
{remote && <div className="viewing">
|
||||
<b>Viewing your shops from here.</b> These cameras are wired to the shop
|
||||
computers, so they are set up and checked there. The picture is each
|
||||
camera's most recent frame, not live video.
|
||||
computers, so they are set up and checked there. Each tile shows that
|
||||
camera's most recent frame; <b>Watch live</b> asks the shop computer to
|
||||
send video for as long as you are looking.
|
||||
</div>}
|
||||
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
@@ -66,7 +80,10 @@ export default function Cameras() {
|
||||
</div>
|
||||
: <div className="camgrid">
|
||||
{cams.map(c => (
|
||||
<CameraCard key={c.id} cam={c} stream={streams[c.id]}
|
||||
<CameraCard key={c.id} cam={c}
|
||||
stream={watching === c.id ? watchURL : streams[c.id]}
|
||||
watching={watching === c.id}
|
||||
onWatch={() => setWatching(watching === c.id ? null : c.id)}
|
||||
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
|
||||
))}
|
||||
</div>}
|
||||
@@ -78,14 +95,23 @@ export default function Cameras() {
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
|
||||
// Three states, not two, and the third is why `connected` is a pointer on
|
||||
// the wire: null means no shop computer has reported on this camera yet,
|
||||
// which reads as waiting rather than as a fault to go and investigate.
|
||||
const conn = cam.connected === undefined || cam.connected === null
|
||||
? (cam.remote ? { tone: 'idle', label: 'Waiting for the shop computer' }
|
||||
: { tone: 'idle', label: 'Engine stopped' })
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
|
||||
const conn = cam.remote
|
||||
// Four states, decided once by the server. `stale` is the one that was
|
||||
// missing: the shop computer reports nothing when it cannot reach its own
|
||||
// engine, so its last report used to sit there reading Connected -
|
||||
// measured at 34 minutes on the live estate.
|
||||
? ({ connected: { tone: 'ok', label: 'Connected' },
|
||||
not_connecting: { tone: 'bad', label: 'Not connecting' },
|
||||
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|
||||
|| { tone: 'idle', label: 'Waiting for the shop computer' })
|
||||
: cam.connected === undefined || cam.connected === null
|
||||
? { tone: 'idle', label: 'Engine stopped' }
|
||||
: cam.connected ? { tone: 'ok', label: 'Connected' }
|
||||
: { tone: 'bad', label: 'Not connecting' }
|
||||
// The last placement verdict, so "proven" survives closing the sheet. Only
|
||||
// `good` is a pass: marginal means half the visitors are silently discarded.
|
||||
// Never asked for a remote camera: that answer lives on the shop computer,
|
||||
@@ -105,6 +131,9 @@ function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
? <img src={stream || shot} alt={cam.id} />
|
||||
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
|
||||
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
|
||||
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
|
||||
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
|
||||
</button>}
|
||||
</div>
|
||||
<div className="cambody">
|
||||
<div className="camtitle">
|
||||
@@ -121,9 +150,13 @@ function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
|
||||
</div>
|
||||
{cam.remote
|
||||
? <div className="camproof">
|
||||
<span className="note">{cam.snapshot?.available
|
||||
? 'Last picture from the shop computer.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
<span className="note">{cam.state_note
|
||||
? cam.state_note
|
||||
: watching
|
||||
? 'Live from the shop computer. It uploads only while you watch.'
|
||||
: cam.snapshot?.available
|
||||
? 'Last picture from the shop computer. Watch live to see it now.'
|
||||
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
|
||||
</div>
|
||||
: <div className="camproof">
|
||||
<span className={`tag ${proof.tone}`}>{proof.label}</span>
|
||||
|
||||
Reference in New Issue
Block a user