/** * settings.js — settings panel (frontend) */ (function() { 'use strict'; let _brands = {}; // Hamlib model cache let _rigs = []; // radio config cache const BAUDS = ['1200','2400','4800','9600','19200','38400','57600','115200']; // rig.name is a free-typed attribute value (value="...") - escape quotes too, // not just text content. This name is later shown to every operator (not // just admins) in ui.js's rig selector - same injection class as that fix. function _esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function buildModelOptions(sel, brands, current) { sel.innerHTML = ''; Object.entries(brands).forEach(([brand, models]) => { const og = document.createElement('optgroup'); og.label = brand; models.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; if (String(m.id) === String(current)) opt.selected = true; og.appendChild(opt); }); sel.appendChild(og); }); } function renderRigs(brands, rigs) { _brands = brands || _brands; _rigs = rigs || _rigs; const container = document.getElementById('rigs-settings-container'); const addBtn = document.getElementById('btn-add-rig'); const isAdmin = window.AppState?.role === 'admin'; if (!container) return; // The "ADD RADIO" button is hidden — the current architecture only // supports one radio. (see addRig() below). Stays in the DOM, but hidden. if (addBtn) addBtn.style.display = 'none'; container.innerHTML = ''; (_rigs.length ? _rigs : [{}]).forEach((rig, idx) => { const id = rig.id || (idx + 1); container.appendChild(buildRigPanel(id, rig, isAdmin)); }); // Load audio cards and COM port lists after the DOM has rendered (short delay) setTimeout(() => loadAudioCards(), 100); setTimeout(() => loadPortSelects(), 100); } // Populate every radio's COM port
${I18n.t('cfg_card_rx_desc')}
${I18n.t('cfg_card_tx_desc')}
${I18n.t('cfg_dtr_desc')}
${I18n.t('cfg_line_lbl')}
${I18n.t('cfg_dtr_port_lbl')}
${id===1?`
${I18n.t('cfg_rig1_note')}
`:''} `; return div; } // Admin: add a new radio // // DISABLED: the current architecture supports only ONE radio at a time — // one audio stream, one radio_lock, one CI-V instance, one set of Rust // ports. Adding a second radio would save to the list, but the server // still only connects to rigs[0], so the operator would get a silent, // non-functional radio. To avoid misleading anyone, adding is blocked. // Full multi-radio support (parallel operation) is a separate, large // undertaking — see the project notes. function addRig() { window.UI?.showToast?.(I18n.t('cfg_single_rig_notice'), 'info'); } // Admin: remove a radio (radio 1 cannot be removed) function removeRig(id) { if (!window.AppState || window.AppState.role !== 'admin' || id === 1) return; _rigs = _rigs.filter(r => r.id !== id); renderRigs(_brands, _rigs); } // Backward-compatibility alias function populateModels(brands, rigs) { renderRigs(brands, rigs); } // ── Audio cards ─────────────────────────────────────────────────────────────── let _audioDevices = { rx:[], tx:[] }; // cache async function loadAudioCards(rigId) { try { const r = await fetch('/api/audio/enumerate'); if (!r.ok) return; const data = await r.json(); _audioDevices = data.devices || { rx:[], tx:[] }; // Populate every visible radio const ids = rigId ? [rigId] : _rigs.map(r => r.id).filter(Boolean); if (!ids.length) ids.push(1); // fallback to radio 1 for (const id of ids) { fillAudioSelect(`cfg-audio-rx-${id}`, _audioDevices.rx); fillAudioSelect(`cfg-audio-tx-${id}`, _audioDevices.tx); } // Restore saved values from _rigs. Exact match, with a fallback to a // partial match (contained in / contains) - Windows can return a USB // card's name slightly differently between successive queries, and // select.value silently falls back to the first option when there's // no exact match, even though the card is still saved correctly. const _selectFuzzy = (sel, wanted) => { if (!sel || !wanted) return; for (const opt of sel.options) if (opt.value === wanted) { sel.value = wanted; return; } for (const opt of sel.options) { if (opt.value && (opt.value.includes(wanted) || wanted.includes(opt.value))) { sel.value = opt.value; return; } } }; _rigs.forEach(rig => { if (rig.audioRx) _selectFuzzy(document.getElementById(`cfg-audio-rx-${rig.id}`), rig.audioRx); if (rig.audioTx) _selectFuzzy(document.getElementById(`cfg-audio-tx-${rig.id}`), rig.audioTx); }); const cnt = _audioDevices.rx?.length || 0; if (cnt > 0) { console.log(`[audio] ${cnt} audio cards loaded`); } else { console.log('[audio] No cards found (Windows: requires FFmpeg or PowerShell WMI)'); } } catch(e) { console.warn('[audio] loadAudioCards:', e.message); } } function fillAudioSelect(elId, devices) { const sel = document.getElementById(elId); if (!sel) return; // Keep the current value (may come from the saved config) const current = sel.value || sel.getAttribute('data-saved') || ''; // Keep the "saved card" option if it's not in the list const savedOpt = current && !(devices||[]).includes(current) ? current : null; sel.innerHTML = ``; if (savedOpt) { const opt = document.createElement('option'); opt.value = opt.textContent = savedOpt; opt.selected = true; sel.appendChild(opt); } // "USB Audio CODEC" devices (the built-in codec of the IC-7300/7610/ // 9700/705 etc.) are sorted to the top and marked ⭐ — this is the // correct choice, since audio to/from the radio goes over USB, not // through the external ACC/AF Out/Mic jacks. const sorted = [...(devices || [])].sort((a, b) => { const au = /usb audio/i.test(a) ? 0 : 1; const bu = /usb audio/i.test(b) ? 0 : 1; return au - bu; }); sorted.forEach(d => { const opt = document.createElement('option'); opt.value = d; opt.textContent = /usb audio/i.test(d) ? `⭐ ${d}` : d; if (d === current) opt.selected = true; sel.appendChild(opt); }); if (current) sel.value = current; } function setAudioSelects(rigId, audioRx, audioTx) { const rx = document.getElementById(`cfg-audio-rx-${rigId}`); const tx = document.getElementById(`cfg-audio-tx-${rigId}`); if (rx) { // If the value doesn't have an option yet — add it temporarily if (audioRx && !rx.querySelector(`option[value="${audioRx}"]`)) { const opt = document.createElement('option'); opt.value = opt.textContent = audioRx; rx.appendChild(opt); } rx.value = audioRx || ''; } if (tx) { if (audioTx && !tx.querySelector(`option[value="${audioTx}"]`)) { const opt = document.createElement('option'); opt.value = opt.textContent = audioTx; tx.appendChild(opt); } tx.value = audioTx || ''; } } async function saveRig(id) { const get = el => document.getElementById(el)?.value || ''; const body = { id, name: get(`cfg-name-${id}`) || `Radio ${id}`, model: get(`cfg-model-${id}`) || '3073', port: get(`cfg-port-${id}`) || `COM${id+2}`, speed: get(`cfg-speed-${id}`) || '19200', civ: get(`cfg-civ-${id}`) || '0x94', audioRx: get(`cfg-audio-rx-${id}`), audioTx: get(`cfg-audio-tx-${id}`), active: id === 1, }; // CW Keyer settings (method/port/line/USB Keying) are deliberately NOT // part of this save - they live in their OWN config path // (self.cfg["cwMethod"/"cwDtrPort"/...], set via Settings._cwKeyerSave // -> /api/cw/dtr-port) with their own save button below. They used to // ALSO get written here into the per-rig object, a second, dead copy // nothing ever read back - it just meant this button and the CW Keyer // button could silently overwrite each other's idea of the current // port/line depending on which was clicked last (reported live // 2026-08-26 as "radio config lost its settings"). // Update the cache const idx = _rigs.findIndex(r => r.id === id); if (idx >= 0) _rigs[idx] = { ..._rigs[idx], ...body }; try { const r = await fetch(`/api/config/rig/${id}`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); const res = await r.json(); if (res.ok) { window.UI?.showToast(I18n.t('cfg_toast_rig_saved').replace('{id}', id).replace('{name}', body.name)); } else { window.UI?.showToast('✗ ' + (res.error||I18n.t('profile_error_fallback')), 'error'); } } catch(e) { window.UI?.showToast('✗ ' + I18n.t('cfg_conn_error'), 'error'); } } async function connectRig(id) { const statusEl = document.getElementById(`rig-connect-status-${id}`); if (statusEl) statusEl.textContent = I18n.t('cfg_connecting'); UI.showToast(I18n.t('cfg_toast_connecting_radio')); try { // Get the current settings from the form const model = document.getElementById(`cfg-model-${id}`)?.value; const port = document.getElementById(`cfg-port-${id}`)?.value; const speed = document.getElementById(`cfg-speed-${id}`)?.value; const civ = document.getElementById(`cfg-civ-${id}`)?.value; const r = await fetch('/api/rig/connect', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ rigId: id, model, port, speed, civ }), }); const res = await r.json(); if (res.ok) { const msg = res.sim ? I18n.t('cfg_sim_mode_msg').replace('{msg}', res.message||I18n.t('cfg_not_found')) : `✓ ${res.message||I18n.t('cfg_connected_fallback')}`; UI.showToast(msg, res.sim ? 'warn' : 'ok'); if (statusEl) { statusEl.textContent = res.sim ? I18n.t('cfg_sim_no_rigctld').replace('{path}', res.rigctldPath||I18n.t('cfg_not_found')) : I18n.t('cfg_connected_status'); statusEl.style.color = res.sim ? 'var(--amber)' : 'var(--green)'; } // Refresh the radio features panel (VFO A/B, sliders, func toggle) — // the new radio may have a different set of capabilities window.RadioFunctions?.refresh(); window.Admin?.loadRigFeatures?.(); } else { const err = res.error || I18n.t('cfg_conn_error'); UI.showToast('✗ ' + err, 'error'); if (statusEl) { statusEl.textContent = '✗ ' + err; statusEl.style.color = 'var(--red)'; statusEl.style.whiteSpace = 'normal'; } } } catch(e) { UI.showToast('✗ ' + I18n.t('cfg_conn_error') + ': ' + e.message, 'error'); } } async function loadStatus() { try { const r = await fetch('/api/health'); const res = await r.json(); const el = document.getElementById('health-status'); if (el) el.innerHTML = `
Uptime: ${res.uptime}s
Node.js: ${res.node}
Platforma: ${res.platform}
Hamlib: ${res.hamlib ? 'POŁĄCZONO' : 'SYMULACJA'}
Audio: ${res.audio ? 'AKTYWNE' : 'NIEAKTYWNE'}
Klientów WS: ${res.listeners}
`; } catch(e) {} } async function toggleAudio(on) { const rxDevice = document.getElementById('cfg-audio-rx-1')?.value || null; // CRITICAL: WS.enableAudio() only registers this client as a subscriber // to the audio stream (WS 'audio_start') — it does NOT start the actual // read from the sound card/microphone on the server side. That's a // separate operation: audio_stream.py::start_rx() is called ONLY via // HTTP POST /api/audio/rx/start, which nothing in the frontend ever // called — effect: click "Enable RX audio", the subscription registers, // OpusDecoder works fine, but the server never opens the PyAudio input // stream (no "[audio] RX START" in the logs) so there's no data to // send — total silence with no error at all. We now call both paths in // parallel when enabling/disabling. if (on) { try { const r = await fetch('/api/audio/rx/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device: rxDevice }) }); const res = await r.json(); if (!res.ok) { UI.showToast('✗ Nie udalo sie uruchomic audio RX na serwerze', 'error'); console.error('[audio] /api/audio/rx/start nieudane:', res); } } catch (e) { UI.showToast('✗ Blad uruchamiania audio RX', 'error'); console.error('[audio] /api/audio/rx/start blad:', e); } } else { try { await fetch('/api/audio/rx/stop', { method: 'POST' }); } catch (e) {} } await WS.enableAudio(on, rxDevice); const btn = document.getElementById('audio-toggle-btn'); if (on) { UI.showToast('Włączono audio RX' + (rxDevice ? ': ' + rxDevice.slice(0,25) : '')); if (btn) { btn.textContent = '🔇 Wyłącz audio'; btn.style.color='var(--green)'; } } else { UI.showToast('Audio wyłączone'); if (btn) { btn.textContent = '🔊 Włącz audio RX'; btn.style.color=''; } } } // ── CW Keyer (DTR/RTS only - CAT/cmd-17 retired 2026-08-26) ─────────────────── // One save, one backend call: /api/cw/dtr-port does everything server-side // (cwMethod, cwDtrPort/cwDtrLine, configure_keyer, derives+sends the // matching CI-V USB Keying value, turns BK-IN on) - it needs the REAL CI-V // port to correctly tell "same port as CI-V" from "a genuinely separate // second port", which only the backend (self.rig.port) actually knows; // guessing that here from just the PORT field's emptiness broke for a // user who types the SAME port explicitly instead of leaving it blank. async function _cwKeyerSave(id) { const line = document.getElementById(`cfg-cw-dtr-line-${id}`)?.value || 'DTR'; const port = document.getElementById(`cfg-cw-dtr-port-${id}`)?.value.trim() || ''; const status = document.getElementById(`cfg-cw-dtr-status-${id}`); const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token'); if (status) { status.textContent = I18n.t('cfg_configuring'); status.style.color = 'var(--dim)'; } const r = await fetch('/api/cw/dtr-port', { method: 'POST', headers: {'Content-Type':'application/json','Authorization':`Bearer ${token}`}, body: JSON.stringify({port, line}), }).then(r => r.json()).catch(e => ({ok:false, error:e.message})); if (status) { status.textContent = r.ok ? `✓ ${r.message}` : `✗ ${r.error}`; status.style.color = r.ok ? (r.usbKeyingOk === false ? 'var(--amber)' : 'var(--green)') : 'var(--red)'; } } // ── Initialize the CW keyer state when loading rig settings ────────────────── async function _initCwStatus() { const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token'); try { const d = await fetch('/api/cw/status', { headers: {'Authorization': `Bearer ${token}`} }).then(r => r.json()); document.querySelectorAll('[id^="cfg-cw-dtr-line-"]').forEach(async lineEl => { const id = lineEl.id.replace('cfg-cw-dtr-line-',''); lineEl.value = (d.method === 'rts') ? 'RTS' : (d.dtrLine || 'DTR'); const portEl = document.getElementById(`cfg-cw-dtr-port-${id}`); if (portEl) await window.SerialPorts?.populate(portEl, d.dtrPort || '', I18n.t('cfg_dtr_port_ph')); }); } catch(e) {} } window.Settings = { populateModels, renderRigs, saveRig, connectRig, loadStatus, toggleAudio, loadAudioCards, addRig, removeRig, loadAudioDevices: loadAudioCards, loadPortSelects, _cwKeyerSave, _initCwStatus, }; })();