/* V4L2 control type codes (must match CTRL_TYPE_* in v4l2_ctrl.h) */ const CTRL_INTEGER = 1; const CTRL_BOOLEAN = 2; const CTRL_MENU = 3; const CTRL_BUTTON = 4; const CTRL_INTEGER_MENU = 9; /* V4L2 control flags */ const FLAG_DISABLED = 0x0001; const FLAG_GRABBED = 0x0002; const FLAG_READ_ONLY = 0x0004; const FLAG_INACTIVE = 0x0010; /* V4L2_CAP bits (from linux/videodev2.h) */ const CAP_VIDEO_CAPTURE = 0x00000001; const CAP_META_CAPTURE = 0x00800000; /* ------------------------------------------------------------------------- * State * ------------------------------------------------------------------------- */ let selected_device_idx = null; let device_data = null; /* last ENUM_DEVICES result */ /* ------------------------------------------------------------------------- * Utilities * ------------------------------------------------------------------------- */ const $ = id => document.getElementById(id); function toast(msg, type = '') { const el = document.createElement('div'); el.className = 'toast' + (type ? ` ${type}` : ''); el.textContent = msg; $('toast-container').appendChild(el); setTimeout(() => { el.classList.add('fading'); setTimeout(() => el.remove(), 450); }, 2500); } async function api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) { opts.body = JSON.stringify(body); } const res = await fetch(path, opts); const data = await res.json(); if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } return data; } function caps_tags(device_caps) { const tags = []; if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); } if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); } return tags.join(' '); } /* ------------------------------------------------------------------------- * Connection UI * ------------------------------------------------------------------------- */ async function refresh_status() { try { const s = await api('GET', '/api/status'); const dot = $('status-dot'); const text = $('status-text'); if (s.connected) { dot.className = 'ok'; text.textContent = `${s.host}:${s.port}`; $('btn-disconnect').style.display = ''; $('btn-connect').style.display = 'none'; await refresh_devices(); } else { dot.className = ''; text.textContent = 'disconnected'; $('btn-disconnect').style.display = 'none'; $('btn-connect').style.display = ''; show_empty_devices(); } } catch (err) { console.error(err); } } $('btn-connect').addEventListener('click', async () => { const host = $('inp-host').value.trim(); const port = $('inp-port').value.trim(); if (!host || !port) { toast('enter host and port', 'err'); return; } try { await api('POST', '/api/connect', { host, port: parseInt(port) }); toast('connected', 'ok'); await refresh_status(); } catch (err) { toast(err.message, 'err'); } }); let discovery_es = null; $('btn-discover').addEventListener('click', () => { if (discovery_es) { return; } /* already open */ show_peer_picker(); }); async function connect_to_peer(peer) { await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port }); $('inp-host').value = peer.addr; $('inp-port').value = peer.tcp_port; toast(`connected to ${peer.name}`, 'ok'); } function show_peer_picker() { /* -- overlay ----------------------------------------------------------- */ const overlay = document.createElement('div'); overlay.style.cssText = ` position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:200; `; const box = document.createElement('div'); box.style.cssText = ` background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:20px; min-width:320px; max-width:480px; `; const header = document.createElement('div'); header.style.cssText = 'display:flex; align-items:center; gap:10px; margin-bottom:14px;'; const title = document.createElement('div'); title.style.cssText = 'font-weight:600; flex:1;'; title.textContent = 'Discovering nodes…'; const spinner = document.createElement('span'); spinner.textContent = '⟳'; spinner.style.cssText = 'animation:spin 1s linear infinite; display:inline-block; font-size:16px;'; const cancel_btn = document.createElement('button'); cancel_btn.textContent = 'Close'; header.appendChild(title); header.appendChild(spinner); header.appendChild(cancel_btn); box.appendChild(header); const peer_list = document.createElement('div'); peer_list.style.cssText = 'display:flex; flex-direction:column; gap:6px; min-height:40px;'; const hint = document.createElement('div'); hint.style.cssText = 'color:var(--text-dim); font-size:12px; padding:8px 0;'; hint.textContent = 'Waiting for announcements…'; peer_list.appendChild(hint); box.appendChild(peer_list); overlay.appendChild(box); document.body.appendChild(overlay); /* add spin keyframe once */ if (!document.getElementById('spin-style')) { const s = document.createElement('style'); s.id = 'spin-style'; s.textContent = '@keyframes spin { to { transform:rotate(360deg); } }'; document.head.appendChild(s); } /* -- SSE --------------------------------------------------------------- */ const es = new EventSource('/api/discover'); discovery_es = es; es.onmessage = e => { const peer = JSON.parse(e.data); hint.remove(); title.textContent = 'Select a node:'; const btn = document.createElement('button'); btn.style.cssText = 'display:flex; width:100%; text-align:left; padding:8px 12px; gap:12px; align-items:baseline;'; btn.innerHTML = ` ${peer.name} ${peer.addr}:${peer.tcp_port} `; btn.addEventListener('click', async () => { close_picker(); try { await connect_to_peer(peer); await refresh_status(); } catch (err) { toast(err.message, 'err'); } }); peer_list.appendChild(btn); }; es.onerror = () => { title.textContent = 'Discovery error'; spinner.style.display = 'none'; }; /* -- close ------------------------------------------------------------- */ function close_picker() { es.close(); discovery_es = null; overlay.remove(); } cancel_btn.addEventListener('click', close_picker); overlay.addEventListener('click', e => { if (e.target === overlay) { close_picker(); } }); } $('btn-disconnect').addEventListener('click', async () => { await api('POST', '/api/disconnect'); selected_device_idx = null; device_data = null; show_empty_devices(); show_empty_controls(); await refresh_status(); }); $('btn-refresh-devices').addEventListener('click', refresh_devices); /* ------------------------------------------------------------------------- * Device list * ------------------------------------------------------------------------- */ function show_empty_devices() { $('device-list').innerHTML = '
Not connected
'; } function show_empty_controls(msg = 'Select a device') { $('controls-title').textContent = 'Controls'; $('controls-scroll').innerHTML = `
${msg}
`; } async function refresh_devices() { try { device_data = await api('GET', '/api/devices'); render_device_list(); } catch (err) { toast(err.message, 'err'); } } function render_device_list() { const list = $('device-list'); list.innerHTML = ''; if (!device_data) { return; } let flat_idx = 0; const { media, standalone } = device_data; if (media.length > 0) { for (const md of media) { const group = document.createElement('div'); group.className = 'device-group'; const gh = document.createElement('div'); gh.className = 'device-group-header'; gh.textContent = `${md.model} (${md.driver})`; group.appendChild(gh); for (const vn of md.video_nodes) { const idx = flat_idx++; group.appendChild(make_device_item(idx, vn.path, vn.entity_name, vn.device_caps, vn.is_capture)); } list.appendChild(group); } } if (standalone.length > 0) { const group = document.createElement('div'); group.className = 'device-group'; const gh = document.createElement('div'); gh.className = 'device-group-header'; gh.textContent = 'Standalone'; group.appendChild(gh); for (const sd of standalone) { const idx = flat_idx++; group.appendChild(make_device_item(idx, sd.path, sd.name, 0, false)); } list.appendChild(group); } if (flat_idx === 0) { list.innerHTML = '
No devices found
'; } /* re-select previously selected device */ if (selected_device_idx !== null) { const item = list.querySelector(`[data-idx="${selected_device_idx}"]`); if (item) { item.classList.add('selected'); } } } function make_device_item(idx, path, label, device_caps, is_capture) { const el = document.createElement('div'); el.className = 'device-item'; el.dataset.idx = idx; const caps_text = caps_tags(device_caps); const badge = is_capture ? 'capture' : ''; el.innerHTML = `
${path}${badge}
${label}
${caps_text ? `
${caps_text}
` : ''} `; el.addEventListener('click', () => select_device(idx, path)); return el; } async function select_device(idx, path) { selected_device_idx = idx; document.querySelectorAll('.device-item').forEach(el => { el.classList.toggle('selected', parseInt(el.dataset.idx) === idx); }); await load_controls(idx, path); } /* ------------------------------------------------------------------------- * Controls * ------------------------------------------------------------------------- */ async function load_controls(device_idx, device_path) { $('controls-title').textContent = `Controls — ${device_path}`; $('controls-scroll').innerHTML = '
Loading…
'; try { const result = await api('GET', `/api/devices/${device_idx}/controls`); render_controls(device_idx, result.controls ?? []); } catch (err) { toast(err.message, 'err'); show_empty_controls('Failed to load controls'); } } /* * V4L2 control IDs with their class prefix (from linux/v4l2-controls.h). * Used for grouping controls into sections. */ const CTRL_CLASSES = [ { base: 0x00980000, name: 'User' }, { base: 0x009a0000, name: 'Camera' }, { base: 0x009b0000, name: 'Flash' }, { base: 0x009c0000, name: 'JPEG' }, { base: 0x009e0000, name: 'Image Source' }, { base: 0x009f0000, name: 'Image Proc' }, { base: 0x00a20000, name: 'Codec' }, ]; function ctrl_class_name(id) { for (const c of CTRL_CLASSES) { if ((id & 0xFFFF0000) === c.base) { return c.name; } } return 'Other'; } function render_controls(device_idx, controls) { const scroll = $('controls-scroll'); scroll.innerHTML = ''; if (controls.length === 0) { scroll.innerHTML = '
No controls
'; return; } const groups = new Map(); for (const ctrl of controls) { const cls = ctrl_class_name(ctrl.id); if (!groups.has(cls)) { groups.set(cls, []); } groups.get(cls).push(ctrl); } for (const [cls_name, ctrls] of groups) { const group_el = document.createElement('div'); group_el.className = 'ctrl-group'; const title = document.createElement('div'); title.className = 'ctrl-group-title'; title.textContent = cls_name; group_el.appendChild(title); for (const ctrl of ctrls) { const row = make_ctrl_row(device_idx, ctrl); if (row) { group_el.appendChild(row); } } scroll.appendChild(group_el); } } function make_ctrl_row(device_idx, ctrl) { const disabled = !!(ctrl.flags & FLAG_DISABLED); const read_only = !!(ctrl.flags & (FLAG_READ_ONLY | FLAG_GRABBED)); const row = document.createElement('div'); row.className = 'ctrl-row' + (disabled ? ' disabled' : '') + (read_only ? ' readonly' : ''); const label_el = document.createElement('div'); label_el.className = 'ctrl-label'; label_el.textContent = ctrl.name; row.appendChild(label_el); const input_wrap = document.createElement('div'); input_wrap.className = 'ctrl-input'; const value_el = document.createElement('div'); value_el.className = 'ctrl-value-display'; let input_el = null; let get_value = null; switch (ctrl.type) { case CTRL_BOOLEAN: { input_el = document.createElement('input'); input_el.type = 'checkbox'; input_el.checked = !!ctrl.current_val; get_value = () => input_el.checked ? 1 : 0; value_el.textContent = ''; break; } case CTRL_MENU: case CTRL_INTEGER_MENU: { input_el = document.createElement('select'); for (const item of ctrl.menu_items) { const opt = document.createElement('option'); opt.value = item.index; opt.textContent = ctrl.type === CTRL_INTEGER_MENU ? item.int_value.toString() : item.name; if (item.index === ctrl.current_val) { opt.selected = true; } input_el.appendChild(opt); } get_value = () => parseInt(input_el.value); value_el.textContent = ''; break; } case CTRL_BUTTON: { input_el = document.createElement('button'); input_el.textContent = ctrl.name; input_el.style.width = '100%'; get_value = () => 1; value_el.textContent = ''; break; } case CTRL_INTEGER: default: { const range = document.createElement('input'); range.type = 'range'; range.min = ctrl.min; range.max = ctrl.max; range.step = ctrl.step || 1; range.value = ctrl.current_val; value_el.textContent = ctrl.current_val; range.addEventListener('input', () => { value_el.textContent = range.value; }); input_el = range; get_value = () => parseInt(range.value); break; } } if (!input_el) { return null; } input_wrap.appendChild(input_el); row.appendChild(input_wrap); row.appendChild(value_el); /* send SET_CONTROL on change/click */ if (!read_only && !disabled) { const send = async () => { try { const id_hex = ctrl.id.toString(16).padStart(8, '0'); await api('POST', `/api/devices/${device_idx}/controls/${id_hex}`, { value: get_value() }); } catch (err) { toast(`${ctrl.name}: ${err.message}`, 'err'); } }; if (ctrl.type === CTRL_BUTTON) { input_el.addEventListener('click', send); } else if (ctrl.type === CTRL_BOOLEAN || ctrl.type === CTRL_MENU || ctrl.type === CTRL_INTEGER_MENU) { input_el.addEventListener('change', send); } else { /* slider: send on pointerup so we don't flood the node */ input_el.addEventListener('pointerup', send); input_el.addEventListener('keyup', send); } } return row; } /* ------------------------------------------------------------------------- * Init * ------------------------------------------------------------------------- */ refresh_status();