`;
}).join('');
}
// QSY to a spot's frequency + set the mode (if known).
// ── Convert a spot's mode to the radio's mode (IC-7300) ────────────────────
//
// A cluster spot gives the "operating" mode (SSB, FT8, CW, RTTY...), but
// the radio only understands LSB/USB/CW/RTTY/AM/FM. It needs translating
// — in particular picking the CORRECT SIDEBAND for SSB and digital modes.
//
// Amateur convention (IARU):
// - below 10 MHz -> LSB (160m, 80m, 40m)
// - above 10 MHz -> USB (20m, 17m, 15m, 12m, 10m, 6m and up)
// - 60m (5.3 MHz) -> USB (exception! 60m channels are always USB)
// - digital modes -> always USB (regardless of band)
//
// Returns null when changing the mode doesn't make sense (unknown / '?').
function _spotModeToRigMode(mode, freq_hz) {
const m = String(mode || '').trim().toUpperCase();
if (!m || m === '?') return null;
// Sideband for phone: <10 MHz = LSB, >=10 MHz = USB.
// Exception: 60m (5250-5450 kHz) - internationally always USB.
const is60m = freq_hz >= 5_250_000 && freq_hz <= 5_450_000;
const phoneSideband = (freq_hz < 10_000_000 && !is60m) ? 'LSB' : 'USB';
// Digital modes: ALWAYS USB (that's the standard, even on 40m/80m).
// The radio can be in USB or USB-D - both work, USB-D is better (filter).
const DIGI = ['FT8', 'FT4', 'RTTY', 'PSK', 'PSK31', 'JT65', 'JT9',
'DIGI', 'DATA', 'MFSK', 'OLIVIA', 'JS8', 'WSPR',
'MSK144', 'Q65'];
if (DIGI.includes(m)) return 'USB';
// SAT - satellite operation. We don't auto-change the mode, since it
// depends on the satellite (FM/SSB/CW) and the link direction
// (uplink/downlink). The operator sets it themselves.
if (m === 'SAT') return null;
// Phone -> correct sideband by band
if (m === 'SSB' || m === 'PHONE' || m === 'LSB' || m === 'USB') {
// If the spot explicitly says LSB/USB - respect it (the spotter knew better).
// But when it just says "SSB" generically - pick the sideband by frequency.
if (m === 'LSB' || m === 'USB') return m;
return phoneSideband;
}
// CW, AM, FM - unchanged (the radio knows these modes)
if (m === 'CW' || m === 'CWR' || m === 'CW-R') return 'CW';
if (m === 'AM') return 'AM';
if (m === 'FM' || m === 'NFM' || m === 'WFM') return 'FM';
// Unknown mode - don't touch the radio (better to leave it as is)
return null;
}
// IMPORTANT: we use window.UI.sendFreq (the proven mechanism) instead of
// calling WS.send directly - this way QSY behaves exactly like manual
// tuning from the Radio panel (lock check, S.freq update,
// updateFreqDisplay, etc.). It used to call WS.send({type:'freq',freq})
// directly, which bypassed the S.freq/updateFreqDisplay logic and didn't
// always produce a visible change.
function qsy(freq_hz, mode, call) {
// Guard against invalid arguments from the HTML onclick
freq_hz = parseInt(freq_hz, 10);
if (!freq_hz || freq_hz < 100000) {
window.UI?.showToast?.(I18n.t('dx_invalid_freq'), 'error');
return;
}
// Check whether the user can control the radio (an informational
// toast before sending, since without this UI.sendFreq would also
// show a toast but we can explain it more clearly from the DX Cluster tab).
const lock = window.AppState?.radio_lock;
const myUid = String(window.AppState?.my_uid || window.CurrentUser?.id || '');
const role = window.CurrentUser?.role;
const canControl = role === 'admin' || (lock?.locked && String(lock.user_id) === myUid);
if (!canControl) {
const holder = lock?.callsign || lock?.username || '';
const reason = lock?.locked && holder
? I18n.t('dx_trx_busy').replace('{holder}', holder)
: I18n.t('dx_take_trx_first');
window.UI?.showToast?.(I18n.t('dx_cant_qsy').replace('{reason}', reason), 'error');
return;
}
// Use the proven UI.sendFreq — calls _canControlRadio, updates S.freq,
// updateFreqDisplay, updateVFOBadges, scheduleBandMemorySave, and
// sends the WS message. This is EXACTLY what clicking the "Radio" tab
// during tuning does.
if (typeof window.UI?.sendFreq === 'function') {
window.UI.sendFreq(freq_hz);
} else {
// Fallback if UI.sendFreq is unavailable (shouldn't happen)
window.WS?.send?.({ type: 'freq', freq: freq_hz });
}
// Change the mode (if specific) — with a short delay so the freq
// change arrives first (the backend processes messages sequentially,
// but the radio may need a moment between the CI-V freq and mode commands).
const rigMode = _spotModeToRigMode(mode, freq_hz);
if (rigMode) {
setTimeout(() => {
window.WS?.send?.({ type: 'mode', mode: rigMode });
}, 150);
}
const mhz = (freq_hz / 1e6).toFixed(3);
window.UI?.showToast?.(`✓ QSY: ${mhz} MHz${mode && mode !== '?' ? ' ('+mode+')' : ''}`, 'info');
// A spot click (not a bare frequency) also drops the callsign into the
// quick-log bar (#qlog-call, see index.html) - same field the operator
// would type into by hand, so it gets the same worked-before highlight
// and rotor direction hint for free (see qsolog.js's oninput handler).
// Pinned to this frequency: ui.js::updateFreqDisplay clears the field
// again once the VFO drifts more than a few Hz away from it, since at
// that point we're probably not listening to this station anymore.
if (call) {
const callEl = document.getElementById('qlog-call');
if (callEl) {
callEl.value = String(call).toUpperCase();
window.AppState._qlogPinnedFreq = freq_hz;
window.QSOLog?.checkQuickLogWorkedBefore?.(callEl.value);
window.QSOLog?.updateQuickLogHint?.();
}
}
// Switch to the Radio tab so the user sees the freq/mode change.
// setTimeout(0) so the scroll reset (added in setPage) doesn't collide
// with rendering the toast above.
setTimeout(() => { window.UI?.setPage?.('radio'); }, 100);
}
function clearSpots() {
_spots = [];
renderSpots();
}
// Same station+frequency arriving twice counts as the same spot, not two -
// the backend now broadcasts DX spots from EVERY connected cluster login
// (the admin's always-on one AND the operator's own, if they also connect
// one) to everyone, see webapp.py::_dx_broadcast. Different spotters/nodes
// routinely re-report the identical QSO a few Hz apart, so this matches on
// call + frequency WITHIN a tolerance, not an exact freq_hz match. Keeps
// only the freshest copy - used for both live spots (handleSpot) and the
// server's history replay (loadConfig) so the DX CLUSTER tab's table and
// the RADIO tab's waterfall flags both stay duplicate-free from one place.
const _DUP_FREQ_TOLERANCE_HZ = 1000;
function _addSpot(spot) {
const call = (spot.call || '').toUpperCase();
for (let i = _spots.length - 1; i >= 0; i--) {
const s = _spots[i];
if ((s.call || '').toUpperCase() === call &&
Math.abs((s.freq_hz || 0) - (spot.freq_hz || 0)) <= _DUP_FREQ_TOLERANCE_HZ) {
_spots.splice(i, 1);
break;
}
}
_spots.push(spot);
if (_spots.length > _MAX_SPOTS) _spots.splice(0, _spots.length - _MAX_SPOTS);
}
// ── WS handlers ───────────────────────────────────────────────────────────
function handleSpot(msg) {
_addSpot(msg);
// Update the UI only if the tab is active (saves CPU)
const page = document.getElementById('page-dxcluster');
if (page && page.classList.contains('active')) {
renderSpots();
}
}
function handleStatus(msg) {
_connected = msg.status === 'connected';
updateStatusBadge(msg.status, msg.message);
}
function updateStatusBadge(status, message) {
const badge = document.getElementById('dx-status-badge');
const connectBtn = document.getElementById('dx-connect-btn');
const disconnectBtn = document.getElementById('dx-disconnect-btn');
if (!badge) return;
if (status === 'connected') {
badge.textContent = I18n.t('dx_connected');
badge.style.color = 'var(--green)';
badge.style.background = 'rgba(var(--green-rgb),0.15)';
if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = '';
// Hide the config panel after a successful connection (so the spot
// list has more room — we don't force scrolling)
const cfgPanel = document.getElementById('dx-config-panel');
if (cfgPanel) cfgPanel.style.display = 'none';
} else if (status === 'connecting') {
badge.textContent = I18n.t('dx_connecting');
badge.style.color = 'var(--amber)';
badge.style.background = 'rgba(var(--amber-rgb),0.15)';
} else if (status === 'error') {
badge.textContent = I18n.t('dx_error_prefix') + (message || I18n.t('dx_unknown'));
badge.style.color = 'var(--red)';
badge.style.background = 'rgba(var(--red-rgb),0.15)';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
} else {
badge.textContent = I18n.t('dx_disconnected') + (message ? ' — ' + message : '');
badge.style.color = 'var(--dim)';
badge.style.background = 'var(--panel3)';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
}
}
// ── Utility ───────────────────────────────────────────────────────────────
function _escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, m => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[m]));
}
// ── Init ──────────────────────────────────────────────────────────────────
window.addEventListener('app:ready', () => {
loadConfig();
});
// Auto-refresh the age every 30s so "5s ago" -> "1m ago" transitions update
setInterval(() => {
const page = document.getElementById('page-dxcluster');
if (page && page.classList.contains('active')) renderSpots();
}, 30000);
// ── Sending a spot to the cluster ──────────────────────────────────────────
function _spotHdr() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token') || '';
const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
}
function _spotError(msg) {
const el = document.getElementById('dx-spot-error');
if (!el) return;
if (msg) { el.textContent = msg; el.style.display = 'block'; }
else { el.style.display = 'none'; }
}
// Update the preview of the command that will be sent to the cluster
function _updateSpotPreview() {
const call = (document.getElementById('dx-spot-call')?.value || '').trim().toUpperCase();
const freq = (document.getElementById('dx-spot-freq')?.value || '').trim();
const cmt = (document.getElementById('dx-spot-comment')?.value || '').trim();
const prev = document.getElementById('dx-spot-preview');
if (!prev) return;
let cmd = `DX ${freq || '?'} ${call || '?'}`;
if (cmt) cmd += ` ${cmt}`;
prev.textContent = cmd;
}
// Convert the radio's mode to the label used in spots.
// The radio reports USB/LSB/CW/RTTY... and USB-D/LSB-D for digital
// work. For a spot it makes more sense to give the real operating mode
// (FT8 when on an FT8 frequency).
function _guessSpotMode(freqHz, rigMode) {
const m = String(rigMode || '').toUpperCase();
const khz = freqHz / 1000;
// Exact FT8/FT4 frequencies (kHz) - HF + VHF/UHF + microwave
const FT8 = [1840, 3573, 5357, 7074, 10136, 14074, 18100, 21074, 24915, 28074,
50313, 50323, 70100, 70154, 144174, 222065, 432174,
1296174, 2320174, 3400174, 5760174, 10368174, 24048174];
const FT4 = [3575, 7047.5, 10140, 14080, 18104, 21140, 24919, 28180,
50318, 144170];
const near = (list) => list.some(f => Math.abs(khz - f) <= 2);
if (m.includes('-D') || m === 'DATA' || m === 'PKT') {
if (near(FT8)) return 'FT8';
if (near(FT4)) return 'FT4';
return 'DIGI';
}
if (m.startsWith('CW')) return 'CW';
if (m.startsWith('RTTY')) return 'RTTY';
if (m === 'USB' || m === 'LSB') {
if (near(FT8)) return 'FT8'; // someone forgot to switch to -D
if (near(FT4)) return 'FT4';
return 'SSB';
}
return m || '';
}
function openSpotDialog() {
const modal = document.getElementById('dx-spot-modal');
if (!modal) return;
// Fill in the current frequency and the radio's mode
const S = window.AppState || {};
const freqHz = parseInt(S.freq, 10) || 0;
const khz = freqHz ? (freqHz / 1000).toFixed(1).replace(/\.0$/, '') : '';
const mode = _guessSpotMode(freqHz, S.mode);
const fEl = document.getElementById('dx-spot-freq');
const cEl = document.getElementById('dx-spot-call');
const mEl = document.getElementById('dx-spot-comment');
if (fEl) fEl.value = khz;
if (cEl) cEl.value = '';
if (mEl) mEl.value = mode ? `${mode} ` : ''; // trailing space - the user types the rest
_spotError('');
_updateSpotPreview();
// Hook up the live preview (once)
if (!modal.dataset.wired) {
['dx-spot-call', 'dx-spot-freq', 'dx-spot-comment'].forEach(id => {
document.getElementById(id)?.addEventListener('input', _updateSpotPreview);
});
// Enter in the call/comment field = send
['dx-spot-call', 'dx-spot-comment'].forEach(id => {
document.getElementById(id)?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); sendSpot(); }
});
});
modal.dataset.wired = '1';
}
modal.classList.add('active'); // CSS: .modal-overlay.active { display:flex }
setTimeout(() => cEl?.focus(), 50); // put the cursor straight into the call field
}
function closeSpotDialog() {
const modal = document.getElementById('dx-spot-modal');
if (!modal) return;
modal.classList.remove('active');
}
async function sendSpot() {
const call = (document.getElementById('dx-spot-call')?.value || '').trim().toUpperCase();
const freqKhz = (document.getElementById('dx-spot-freq')?.value || '').trim();
const comment = (document.getElementById('dx-spot-comment')?.value || '').trim();
// Client-side validation (the backend re-checks it anyway)
if (!call || !/^[A-Z0-9/]{3,16}$/.test(call)) {
_spotError(I18n.t('dx_spot_call_invalid'));
return;
}
const khz = parseFloat(freqKhz);
if (!khz || khz < 1800 || khz > 1300000) {
_spotError(I18n.t('dx_spot_freq_invalid'));
return;
}
_spotError('');
const btn = document.getElementById('dx-spot-send-btn');
if (btn) { btn.disabled = true; btn.textContent = I18n.t('dx_sending'); }
try {
const r = await fetch('/api/dxcluster/spot', {
method: 'POST',
headers: _spotHdr(),
body: JSON.stringify({
call,
freq_hz: Math.round(khz * 1000),
comment,
}),
});
const res = await r.json();
if (res.ok) {
window.UI?.showToast?.(I18n.t('dx_spot_sent').replace('{call}', call).replace('{freq}', freqKhz), 'info');
closeSpotDialog();
} else {
_spotError(res.error || I18n.t('dx_spot_send_fail'));
}
} catch (e) {
_spotError(I18n.t('dx_spot_conn_error').replace('{msg}', e.message));
} finally {
if (btn) { btn.disabled = false; btn.textContent = I18n.t('dx_send_spot_modal_btn'); }
}
}
return {
loadConfig, save, connect, disconnect, sendCommand,
renderSpots, qsy, clearSpots, toggleConfig,
handleSpot, handleStatus,
openSpotDialog, closeSpotDialog, sendSpot,
getSpots: () => _spots,
};
})();