/* 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
.
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
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 };
},
};