import { by_id, qs, clone, show, hide } from '/lib/dom.mjs'; /* 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; /* V4L2_CAP bits (from linux/videodev2.h) */ const CAP_VIDEO_CAPTURE = 0x00000001; const CAP_META_CAPTURE = 0x00800000; /* * 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' }, ]; /* ------------------------------------------------------------------------- * State * ------------------------------------------------------------------------- */ const known_peers = new Map(); /* key: addr:name -> peer */ let selected_peer = null; let selected_device_idx = null; let device_data = null; /* ------------------------------------------------------------------------- * Utilities * ------------------------------------------------------------------------- */ function toast(msg, type = '') { const el = document.createElement('div'); el.className = 'toast' + (type ? ` ${type}` : ''); el.textContent = msg; by_id('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(' '); } function ctrl_class_name(id) { for (const c of CTRL_CLASSES) { if ((id & 0xFFFF0000) === c.base) { return c.name; } } return 'Other'; } function empty_el(msg) { const el = document.createElement('div'); el.className = 'empty'; el.textContent = msg; return el; } /* ------------------------------------------------------------------------- * Node panel — live SSE-fed list * ------------------------------------------------------------------------- */ const discovery_es = new EventSource('/api/discover'); discovery_es.onmessage = e => { const peer = JSON.parse(e.data); const key = `${peer.addr}:${peer.name}`; known_peers.set(key, peer); render_node_list(); }; discovery_es.addEventListener('discovery_error', e => { try { const { error } = JSON.parse(e.data); toast(`discovery: ${error}`, 'err'); } catch {} }); function render_node_list() { const list = by_id('node-list'); if (known_peers.size === 0) { list.replaceChildren(empty_el('Discovering…')); return; } list.replaceChildren(...[...known_peers.values()].map(make_node_item)); } function make_node_item(peer) { const key = `${peer.addr}:${peer.name}`; const el = clone('t-node-item'); if (selected_peer && `${selected_peer.addr}:${selected_peer.name}` === key) { el.classList.add('selected'); } qs('.n-name', el).textContent = peer.name; qs('.n-addr', el).textContent = `${peer.addr}:${peer.tcp_port}`; el.addEventListener('click', () => select_node(peer)); return el; } async function select_node(peer) { try { await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port }); selected_peer = peer; selected_device_idx = null; device_data = null; render_node_list(); await refresh_status(); toast(`connected to ${peer.name}`, 'ok'); } catch (err) { toast(err.message, 'err'); } } /* ------------------------------------------------------------------------- * Status * ------------------------------------------------------------------------- */ async function refresh_status() { try { const s = await api('GET', '/api/status'); by_id('status-dot').className = s.connected ? 'ok' : ''; by_id('status-text').textContent = s.connected ? `${s.host}:${s.port}` : 'not connected'; if (s.connected) { show(by_id('btn-disconnect')); await refresh_devices(); } else { hide(by_id('btn-disconnect')); show_empty_devices(); } } catch (err) { console.error(err); } } /* ------------------------------------------------------------------------- * Device panel * ------------------------------------------------------------------------- */ function show_empty_devices(msg = 'Select a node') { by_id('device-list').replaceChildren(empty_el(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 = by_id('device-list'); if (!device_data) { list.replaceChildren(empty_el('Select a node')); return; } let flat_idx = 0; const children = []; const { media, standalone } = device_data; for (const md of media) { const group = clone('t-device-group'); qs('.device-group-header', group).textContent = `${md.model} (${md.driver})`; for (const vn of md.video_nodes) { group.appendChild(make_device_item(flat_idx++, vn.path, vn.entity_name, vn.device_caps, vn.is_capture)); } children.push(group); } if (standalone.length > 0) { const group = clone('t-device-group'); qs('.device-group-header', group).textContent = 'Standalone'; for (const sd of standalone) { group.appendChild(make_device_item(flat_idx++, sd.path, sd.name, 0, false)); } children.push(group); } if (flat_idx === 0) { list.replaceChildren(empty_el('No devices found')); return; } list.replaceChildren(...children); 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 = clone('t-device-item'); el.dataset.idx = idx; const path_el = qs('.d-path', el); path_el.textContent = path; if (is_capture) { path_el.appendChild(clone('t-capture-badge')); } qs('.d-meta', el).textContent = label; const caps_text = caps_tags(device_caps); if (caps_text) { const caps_el = document.createElement('div'); caps_el.className = 'd-caps'; caps_el.textContent = caps_text; el.appendChild(caps_el); } 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 panel * ------------------------------------------------------------------------- */ function show_empty_controls(msg = 'Select a device') { by_id('controls-title').textContent = 'Controls'; by_id('controls-scroll').replaceChildren(empty_el(msg)); } async function load_controls(device_idx, device_path) { by_id('controls-title').textContent = `Controls — ${device_path}`; by_id('controls-scroll').replaceChildren(empty_el('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'); } } function render_controls(device_idx, controls) { const scroll = by_id('controls-scroll'); if (controls.length === 0) { scroll.replaceChildren(empty_el('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); } const children = []; for (const [cls_name, ctrls] of groups) { const group_el = clone('t-ctrl-group'); qs('.ctrl-group-title', group_el).textContent = cls_name; for (const ctrl of ctrls) { const row = make_ctrl_row(device_idx, ctrl); if (row) { group_el.appendChild(row); } } children.push(group_el); } scroll.replaceChildren(...children); } 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 = clone('t-ctrl-row'); if (disabled) { row.classList.add('disabled'); } if (read_only) { row.classList.add('readonly'); } const label_el = qs('.ctrl-label', row); const input_wrap = qs('.ctrl-input', row); const value_el = qs('.ctrl-value-display', row); label_el.textContent = ctrl.name; 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; 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); break; } case CTRL_BUTTON: { input_el = document.createElement('button'); input_el.textContent = ctrl.name; get_value = () => 1; break; } case CTRL_INTEGER: default: { input_el = document.createElement('input'); input_el.type = 'range'; input_el.min = ctrl.min; input_el.max = ctrl.max; input_el.step = ctrl.step || 1; input_el.value = ctrl.current_val; value_el.textContent = ctrl.current_val; input_el.addEventListener('input', () => { value_el.textContent = input_el.value; }); get_value = () => parseInt(input_el.value); break; } } if (!input_el) { return null; } input_wrap.appendChild(input_el); 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 { input_el.addEventListener('pointerup', send); input_el.addEventListener('keyup', send); input_el.addEventListener('input', () => { if (by_id('chk-continuous').checked) { send(); } }); } } return row; } /* ------------------------------------------------------------------------- * Event wiring * ------------------------------------------------------------------------- */ by_id('btn-connect').addEventListener('click', async () => { const host = by_id('inp-host').value.trim(); const port = by_id('inp-port').value.trim(); if (!host || !port) { toast('enter host and port', 'err'); return; } try { await api('POST', '/api/connect', { host, port: parseInt(port) }); selected_peer = null; selected_device_idx = null; device_data = null; render_node_list(); await refresh_status(); toast('connected', 'ok'); } catch (err) { toast(err.message, 'err'); } }); by_id('btn-disconnect').addEventListener('click', async () => { await api('POST', '/api/disconnect'); selected_peer = null; selected_device_idx = null; device_data = null; render_node_list(); show_empty_devices(); show_empty_controls(); await refresh_status(); }); by_id('btn-refresh-devices').addEventListener('click', refresh_devices); /* ------------------------------------------------------------------------- * Init * ------------------------------------------------------------------------- */ refresh_status();