327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
/* Vision - camera feeds, each switched on by hand.
|
||
|
||
Nothing is subscribed on the robot until a feed is switched on here. That is
|
||
not a nicety: a frame off this robot is 170-430 KB, and the six RGB feeds
|
||
together publish at ~60 Hz. Subscribing to the lot at startup pushed roughly
|
||
15 MB/s through DDS for pictures nobody was looking at, on the same Wi-Fi the
|
||
robot uses to walk. Switching a feed off destroys the subscription on the
|
||
robot rather than merely hiding the <img>.
|
||
|
||
Frames are polled as single images rather than streamed, so a quiet topic
|
||
degrades into "nothing is publishing" instead of a hung connection.
|
||
*/
|
||
|
||
import { store, API, get, post, toast } from '../core.js';
|
||
import { el, card, pageHead, badge, segmented, note, emptyState, button } from '../ui.js';
|
||
|
||
export default {
|
||
id: 'vision',
|
||
label: 'Vision',
|
||
icon: 'vision',
|
||
|
||
async render() {
|
||
const cameras = store.spec?.cameras || [];
|
||
const root = el('div.stack');
|
||
const feeds = new Map();
|
||
|
||
let fps = 2;
|
||
|
||
const fpsControl = segmented(
|
||
[{ value: 1, label: '1 fps' }, { value: 2, label: '2 fps' },
|
||
{ value: 5, label: '5 fps' }, { value: 10, label: '10 fps' }],
|
||
fps,
|
||
(value) => { fps = value; for (const feed of feeds.values()) schedule(feed); },
|
||
);
|
||
|
||
root.appendChild(pageHead(
|
||
'Vision',
|
||
'Every feed is off until you switch it on — that keeps the robot from streaming '
|
||
+ 'pictures nobody is watching. Compressed feeds pass through untouched; depth is '
|
||
+ 'colourised on the robot.',
|
||
[
|
||
fpsControl,
|
||
button('Turn all off', () => stopAll(), { size: 'sm', style: 'ghost' }),
|
||
],
|
||
));
|
||
|
||
if (!cameras.length) {
|
||
root.appendChild(emptyState('No cameras declared', 'The backend reported an empty camera list.'));
|
||
return { node: root };
|
||
}
|
||
|
||
const grid = el('div.grid.cols-2');
|
||
root.appendChild(grid);
|
||
|
||
/* -- One card per camera ---------------------------------------------- */
|
||
|
||
for (const camera of cameras) {
|
||
const feed = {
|
||
camera,
|
||
key: camera.key,
|
||
active: false,
|
||
flip: !!camera.flip,
|
||
busy: false,
|
||
failures: 0,
|
||
timer: null,
|
||
objectUrl: null,
|
||
img: el('img', { alt: `${camera.label} live view`, decoding: 'async' }),
|
||
status: badge('off'),
|
||
meta: el('span', { text: '—' }),
|
||
};
|
||
|
||
feed.img.style.display = 'none';
|
||
feed.placeholder = el('div.cam-placeholder', { text: 'Switched off' });
|
||
|
||
const shell = el('div.cam', {}, feed.placeholder, feed.img,
|
||
el('div.cam-overlay', {},
|
||
el('span.cam-live', { text: camera.key }),
|
||
feed.meta,
|
||
),
|
||
);
|
||
|
||
// The power switch. Checked state follows the robot, not this browser -
|
||
// see applyStreams() - so two tabs never disagree about what is running.
|
||
const box = el('input', { type: 'checkbox' });
|
||
box.addEventListener('change', () => setActive(feed, box.checked));
|
||
feed.checkbox = box;
|
||
const power = el('label.switch', {}, box, el('span.switch-track'),
|
||
el('span.switch-label', { text: 'Off' }));
|
||
feed.powerLabel = power.querySelector('.switch-label');
|
||
|
||
const flipBtn = button('Rotate 180°', () => {
|
||
feed.flip = !feed.flip;
|
||
flipBtn.dataset.on = feed.flip ? '1' : '';
|
||
flipBtn.classList.toggle('btn-primary', feed.flip);
|
||
if (feed.active) tick(feed);
|
||
}, { size: 'sm', style: 'ghost' });
|
||
feed.flipBtn = flipBtn;
|
||
|
||
grid.appendChild(card(camera.label, {
|
||
sub: camera.topic,
|
||
actions: [power, feed.status],
|
||
flush: true,
|
||
foot: el('div.row.between', { style: { gap: '10px', flexWrap: 'wrap' } },
|
||
el('span', {
|
||
text: camera.note || (camera.kind === 'depth'
|
||
? 'Depth map, colourised on the robot.'
|
||
: `${camera.kind} · ~${camera.rate_hz || '?'} Hz`),
|
||
style: { fontSize: '11.5px', color: 'var(--text-3)' },
|
||
}),
|
||
flipBtn,
|
||
),
|
||
}, shell));
|
||
|
||
feeds.set(camera.key, feed);
|
||
}
|
||
|
||
/* -- Hardware inventory ------------------------------------------------ */
|
||
|
||
const TONE = {
|
||
live: 'good', unreachable: 'critical', intermittent: 'warning', absent: 'default',
|
||
};
|
||
const LABEL = {
|
||
live: 'publishing', unreachable: 'not reachable',
|
||
intermittent: 'only while running', absent: 'not fitted',
|
||
};
|
||
|
||
const inventory = store.spec?.camera_inventory || [];
|
||
if (inventory.length) {
|
||
root.appendChild(card('What cameras this robot has', {
|
||
sub: 'Confirmed by decoding a real frame off each topic, not read from the datasheet',
|
||
},
|
||
el('div.stack', { style: { gap: '10px' } },
|
||
...inventory.map((item) => el('div', {
|
||
style: {
|
||
padding: '12px 14px', borderRadius: 'var(--radius-sm)',
|
||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||
},
|
||
},
|
||
el('div.row.between', { style: { marginBottom: '5px' } },
|
||
el('strong', { text: item.name, style: { fontSize: '13px' } }),
|
||
badge(LABEL[item.status] || item.status, TONE[item.status] || 'default'),
|
||
),
|
||
el('div', {
|
||
text: item.detail,
|
||
style: { fontSize: '12.5px', color: 'var(--text-2)', lineHeight: '1.55' },
|
||
}),
|
||
el('div', {
|
||
text: item.where,
|
||
style: { fontSize: '11px', color: 'var(--text-3)', fontFamily: 'var(--mono)',
|
||
marginTop: '5px', wordBreak: 'break-all' },
|
||
}),
|
||
)),
|
||
),
|
||
));
|
||
}
|
||
|
||
root.appendChild(note(
|
||
'Switching a feed off destroys the subscription on the robot, so an unwatched camera '
|
||
+ 'costs nothing. Closing this page turns every feed off by itself.',
|
||
'info',
|
||
));
|
||
|
||
/* -- Switching --------------------------------------------------------- */
|
||
|
||
async function setActive(feed, active) {
|
||
if (feed.busy) return;
|
||
feed.busy = true;
|
||
feed.checkbox.disabled = true;
|
||
setStatus(feed, active ? 'starting…' : 'stopping…', 'default');
|
||
|
||
try {
|
||
await post(`/api/streams/${feed.key}`, { active });
|
||
feed.active = active;
|
||
feed.failures = 0;
|
||
|
||
if (active) {
|
||
feed.placeholder.textContent = 'Waiting for the first frame…';
|
||
feed.placeholder.style.display = 'block';
|
||
schedule(feed);
|
||
} else {
|
||
stop(feed);
|
||
}
|
||
paintPower(feed);
|
||
} catch (err) {
|
||
toast('Could not switch that feed', err.message, 'critical');
|
||
feed.checkbox.checked = feed.active;
|
||
paintPower(feed);
|
||
} finally {
|
||
feed.busy = false;
|
||
feed.checkbox.disabled = false;
|
||
}
|
||
}
|
||
|
||
function paintPower(feed) {
|
||
feed.checkbox.checked = feed.active;
|
||
feed.powerLabel.textContent = feed.active ? 'On' : 'Off';
|
||
if (!feed.active) {
|
||
setStatus(feed, 'off', 'default');
|
||
feed.meta.textContent = '—';
|
||
feed.img.style.display = 'none';
|
||
feed.placeholder.style.display = 'block';
|
||
feed.placeholder.textContent = 'Switched off';
|
||
}
|
||
}
|
||
|
||
function setStatus(feed, text, tone) {
|
||
feed.status.textContent = text;
|
||
feed.status.dataset.tone = tone;
|
||
}
|
||
|
||
function stopAll() {
|
||
for (const feed of feeds.values()) {
|
||
if (feed.active) setActive(feed, false);
|
||
}
|
||
}
|
||
|
||
/* -- Polling ----------------------------------------------------------- */
|
||
|
||
// A feed that keeps coming back empty is retried slowly rather than at the
|
||
// full rate: the perception topics only publish while that module runs, and
|
||
// hammering them fills the console for something working as intended.
|
||
const IDLE_RETRY_MS = 10000;
|
||
const FAILURES_BEFORE_BACKOFF = 3;
|
||
|
||
function schedule(feed) {
|
||
if (feed.timer) clearTimeout(feed.timer);
|
||
feed.timer = null;
|
||
if (!feed.active) return;
|
||
const period = feed.failures >= FAILURES_BEFORE_BACKOFF ? IDLE_RETRY_MS : 1000 / fps;
|
||
feed.timer = setTimeout(() => tick(feed), period);
|
||
}
|
||
|
||
// Fetched rather than assigned straight to img.src: a 204 from a topic
|
||
// nobody publishes to is an ordinary answer here, whereas an <img> that
|
||
// fails to load logs a console error the page cannot suppress.
|
||
async function tick(feed) {
|
||
if (!feed.active) return;
|
||
const url = `${API}/api/camera/${feed.key}/frame`
|
||
+ `?flip=${feed.flip ? 'true' : 'false'}&t=${Date.now()}`;
|
||
|
||
try {
|
||
const res = await fetch(url, { cache: 'no-store' });
|
||
if (res.status === 204 || !res.ok) { onMiss(feed); return; }
|
||
|
||
const blob = await res.blob();
|
||
if (!blob.size) { onMiss(feed); return; }
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const recovered = feed.failures >= FAILURES_BEFORE_BACKOFF;
|
||
|
||
await new Promise((resolve) => {
|
||
feed.img.onload = resolve;
|
||
feed.img.onerror = resolve;
|
||
feed.img.src = objectUrl;
|
||
});
|
||
|
||
// Release the previous frame; without this every frame leaks.
|
||
if (feed.objectUrl) URL.revokeObjectURL(feed.objectUrl);
|
||
feed.objectUrl = objectUrl;
|
||
|
||
feed.failures = 0;
|
||
feed.img.style.display = 'block';
|
||
feed.placeholder.style.display = 'none';
|
||
setStatus(feed, 'live', 'good');
|
||
feed.meta.textContent =
|
||
`${feed.img.naturalWidth}×${feed.img.naturalHeight} · ${Math.round(blob.size / 1024)} KB`;
|
||
if (recovered) toast('Feed restored', feed.camera.label, 'good', 2500);
|
||
} catch {
|
||
onMiss(feed);
|
||
} finally {
|
||
schedule(feed);
|
||
}
|
||
}
|
||
|
||
function onMiss(feed) {
|
||
feed.failures += 1;
|
||
if (feed.failures < 2) return;
|
||
feed.img.style.display = 'none';
|
||
feed.placeholder.style.display = 'block';
|
||
feed.placeholder.textContent = store.bridge?.simulated
|
||
? 'Simulated feed unavailable'
|
||
: `Nothing is publishing on\n${feed.camera.topic}`;
|
||
setStatus(feed, feed.failures >= FAILURES_BEFORE_BACKOFF ? 'not publishing' : 'no signal',
|
||
'warning');
|
||
feed.meta.textContent = '—';
|
||
}
|
||
|
||
function stop(feed) {
|
||
if (feed.timer) clearTimeout(feed.timer);
|
||
feed.timer = null;
|
||
if (feed.objectUrl) URL.revokeObjectURL(feed.objectUrl);
|
||
feed.objectUrl = null;
|
||
}
|
||
|
||
/* -- Follow the robot's own idea of what is running --------------------
|
||
Another browser (or the agent dropping everything when the last client
|
||
left) can change this behind our back. Reading it from state keeps the
|
||
switches honest instead of showing what this tab last asked for. */
|
||
|
||
function applyStreams(streams) {
|
||
if (!streams) return;
|
||
for (const feed of feeds.values()) {
|
||
if (feed.busy) continue;
|
||
const active = !!streams[feed.key]?.active;
|
||
if (active === feed.active) continue;
|
||
feed.active = active;
|
||
paintPower(feed);
|
||
if (active) schedule(feed); else stop(feed);
|
||
}
|
||
}
|
||
|
||
applyStreams(store.state?.custom?.streams);
|
||
const unsubscribe = store.on('state', () => applyStreams(store.state?.custom?.streams));
|
||
|
||
function dispose() {
|
||
unsubscribe();
|
||
for (const feed of feeds.values()) {
|
||
stop(feed);
|
||
// Leaving the tab must not leave the robot streaming. Fire and forget:
|
||
// the page is going away and there is nothing useful to report.
|
||
if (feed.active) post(`/api/streams/${feed.key}`, { active: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
return { node: root, dispose };
|
||
},
|
||
};
|