/** * wsjtx.js — WSJT-X frontend (FT8/FT4/JT65 monitor + personal QSO log) * Each user sees and logs only their own contacts. */ (function() { 'use strict'; const S = window.AppState; // ── State ───────────────────────────────────────────────────────────────────── let _decodes = []; let _allDecodes = []; let _myCall = ''; let _workedCalls = new Set(); // CALL|BAND|MODE keys from the QSO log — each band/mode is a separate QSO let _hideWorked = false; // filter — hide already-worked ones function _workedKey(call, band, mode) { return `${(call||'').toUpperCase()}|${(band||'').toUpperCase()}|${(mode||'').toUpperCase()}`; } // Load callsigns from the log (every 60s and on startup). Uses // /api/qsolog/calls (SELECT DISTINCT call+band+mode, no cap on the // number of entries) instead of /api/qsolog, which has a hard per<=200 // limit on the backend — with a larger log, older QSOs used to // disappear from the worked-marking. Band+mode are in the key, because // "every band (and FT8 vs FT4) is a new QSO": a station worked on 40m // shouldn't gray out on 20m. async function _loadWorkedCalls() { try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/qsolog/calls', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); const d = await r.json(); _workedCalls = new Set((d.calls || []).map(q => _workedKey(q.call, q.band, q.mode))); } catch(e) {} } setInterval(_loadWorkedCalls, 60000); // Whether a callsign has already been worked on the CURRENT band/mode (not globally). function _isWorkedHere(call) { if (!call) return false; const band = window.UI?.getBandName ? window.UI.getBandName(S.freq) : ''; return _workedCalls.has(_workedKey(call, band, _decodeMode)); } let _myGrid = ''; let _status = { running: false, transmit: false, decoding: false }; let _clockTimer = null; let _decodeCount = 0; let _miniLogEntries = []; // recent QSOs from the real log (qso_db, /api/qsolog) - preview under the automation const MINI_LOG_MAX = 8; const MAX_DECODES = 300; // ── Country of the calling station (the "Country" column in Band Activity / RX Frequency) ─ // The column header had been in the HTML for a while, but nothing ever // filled it in - _decodeRowHtml rendered // always empty. A prefix heuristic (not the full/exact DXCC - that would // need an external database of prefix ranges with exceptions), similar to // prefixToLatLon() in beamheading.js (same idea: try the longest prefix // first), but a separate table - here we need a name+flag, not coordinates. const _PREFIX_COUNTRY = { // Europe 'SP':['Polska','PL'], 'SQ':['Polska','PL'], 'SN':['Polska','PL'], 'SO':['Polska','PL'], 'SR':['Polska','PL'], 'HF':['Polska','PL'], '3Z':['Polska','PL'], 'DL':['Niemcy','DE'], 'DK':['Niemcy','DE'], 'DJ':['Niemcy','DE'], 'DF':['Niemcy','DE'], 'DB':['Niemcy','DE'], 'DA':['Niemcy','DE'], 'DC':['Niemcy','DE'], 'DD':['Niemcy','DE'], 'DG':['Niemcy','DE'], 'DH':['Niemcy','DE'], 'DM':['Niemcy','DE'], 'DO':['Niemcy','DE'], 'G':['Anglia','GB'], 'M':['Anglia','GB'], '2E':['Anglia','GB'], 'GW':['Walia','GB'], 'MW':['Walia','GB'], 'GM':['Szkocja','GB'], 'MM':['Szkocja','GB'], 'GI':['Irlandia Płn.','GB'], 'MI':['Irlandia Płn.','GB'], 'GD':['Wyspa Man','GB'], 'GJ':['Jersey','GB'], 'GU':['Guernsey','GB'], 'F':['Francja','FR'], 'TM':['Francja','FR'], 'TO':['Francja','FR'], 'ON':['Belgia','BE'], 'OO':['Belgia','BE'], 'OP':['Belgia','BE'], 'OQ':['Belgia','BE'], 'OR':['Belgia','BE'], 'OS':['Belgia','BE'], 'OT':['Belgia','BE'], 'PA':['Holandia','NL'], 'PB':['Holandia','NL'], 'PC':['Holandia','NL'], 'PD':['Holandia','NL'], 'PE':['Holandia','NL'], 'PF':['Holandia','NL'], 'PG':['Holandia','NL'], 'PH':['Holandia','NL'], 'PI':['Holandia','NL'], 'LX':['Luksemburg','LU'], 'EA':['Hiszpania','ES'], 'EB':['Hiszpania','ES'], 'EC':['Hiszpania','ES'], 'ED':['Hiszpania','ES'], 'EE':['Hiszpania','ES'], 'EF':['Hiszpania','ES'], 'EG':['Hiszpania','ES'], 'EH':['Hiszpania','ES'], 'CT':['Portugalia','PT'], 'CQ':['Portugalia','PT'], 'CS':['Portugalia','PT'], 'CR':['Portugalia','PT'], 'I':['Włochy','IT'], 'IK':['Włochy','IT'], 'IZ':['Włochy','IT'], 'IW':['Włochy','IT'], 'IU':['Włochy','IT'], 'IN':['Włochy','IT'], 'IQ':['Włochy','IT'], 'IR':['Włochy','IT'], 'IT':['Włochy','IT'], 'IS':['Sardynia','IT'], 'IB':['Baleary','ES'], 'HB':['Szwajcaria','CH'], 'HE':['Szwajcaria','CH'], 'OE':['Austria','AT'], 'OK':['Czechy','CZ'], 'OL':['Czechy','CZ'], 'OM':['Słowacja','SK'], 'HA':['Węgry','HU'], 'HG':['Węgry','HU'], 'YO':['Rumunia','RO'], 'YP':['Rumunia','RO'], 'YQ':['Rumunia','RO'], 'YR':['Rumunia','RO'], 'LZ':['Bułgaria','BG'], 'YU':['Serbia','RS'], 'YT':['Serbia','RS'], 'S5':['Słowenia','SI'], '9A':['Chorwacja','HR'], 'E7':['Bośnia i Hercegowina','BA'], 'Z3':['Macedonia Płn.','MK'], 'Z6':['Kosowo','XK'], '4O':['Czarnogóra','ME'], 'SV':['Grecja','GR'], 'SY':['Grecja','GR'], 'SZ':['Grecja','GR'], 'SW':['Grecja','GR'], 'SX':['Grecja','GR'], 'TA':['Turcja','TR'], 'TB':['Turcja','TR'], 'TC':['Turcja','TR'], 'ZA':['Albania','AL'], 'OH':['Finlandia','FI'], 'OF':['Finlandia','FI'], 'OG':['Finlandia','FI'], 'OI':['Finlandia','FI'], 'OJ':['Wyspy Alandzkie','AX'], 'SM':['Szwecja','SE'], 'SA':['Szwecja','SE'], 'SB':['Szwecja','SE'], 'SC':['Szwecja','SE'], 'SD':['Szwecja','SE'], 'SE':['Szwecja','SE'], 'SF':['Szwecja','SE'], 'SG':['Szwecja','SE'], 'SH':['Szwecja','SE'], 'SI':['Szwecja','SE'], 'SJ':['Szwecja','SE'], 'SK':['Szwecja','SE'], 'SL':['Szwecja','SE'], 'LA':['Norwegia','NO'], 'LB':['Norwegia','NO'], 'LJ':['Norwegia','NO'], 'LN':['Norwegia','NO'], 'OZ':['Dania','DK'], '5P':['Dania','DK'], '5Q':['Dania','DK'], 'OU':['Dania','DK'], 'OV':['Dania','DK'], 'OY':['Wyspy Owcze','FO'], 'TF':['Islandia','IS'], 'ES':['Estonia','EE'], 'YL':['Łotwa','LV'], 'LY':['Litwa','LT'], 'EW':['Białoruś','BY'], 'EU':['Białoruś','BY'], 'EV':['Białoruś','BY'], 'UR':['Ukraina','UA'], 'UT':['Ukraina','UA'], 'UX':['Ukraina','UA'], 'US':['Ukraina','UA'], 'UY':['Ukraina','UA'], 'UZ':['Ukraina','UA'], 'EM':['Ukraina','UA'], 'EN':['Ukraina','UA'], 'EO':['Ukraina','UA'], 'ER':['Mołdawia','MD'], 'EI':['Irlandia','IE'], 'EJ':['Irlandia','IE'], 'C3':['Andora','AD'], '3A':['Monako','MC'], '4U':['Watykan/UN','VA'], 'ZB':['Gibraltar','GI'], '9H':['Malta','MT'], '5B':['Cypr','CY'], 'ZC':['Cypr (bryt.)','CY'], 'R':['Rosja (Europ.)','RU'], 'UA':['Rosja (Europ.)','RU'], 'RA':['Rosja (Europ.)','RU'], 'RK':['Rosja (Europ.)','RU'], 'RN':['Rosja (Europ.)','RU'], 'RV':['Rosja (Europ.)','RU'], 'RW':['Rosja (Europ.)','RU'], 'RX':['Rosja (Europ.)','RU'], 'RZ':['Rosja (Europ.)','RU'], 'RC':['Rosja (Europ.)','RU'], 'RD':['Rosja (Europ.)','RU'], 'RG':['Rosja (Europ.)','RU'], 'RJ':['Rosja (Europ.)','RU'], 'RL':['Rosja (Europ.)','RU'], 'RM':['Rosja (Europ.)','RU'], 'RO':['Rosja (Europ.)','RU'], 'RP':['Rosja (Europ.)','RU'], 'RQ':['Rosja (Europ.)','RU'], 'RT':['Rosja (Europ.)','RU'], 'RU':['Rosja (Europ.)','RU'], 'UA9':['Rosja (Azja)','RU'], 'RA9':['Rosja (Azja)','RU'], 'UA0':['Rosja (Azja)','RU'], 'RA0':['Rosja (Azja)','RU'], 'R9':['Rosja (Azja)','RU'], 'R0':['Rosja (Azja)','RU'], '4X':['Izrael','IL'], '4Z':['Izrael','IL'], // N. Africa/Middle East 'CN':['Maroko','MA'], 'SU':['Egipt','EG'], '3V':['Tunezja','TN'], '7X':['Algieria','DZ'], '5A':['Libia','LY'], 'A4':['Oman','OM'], 'A6':['ZEA','AE'], 'A7':['Katar','QA'], 'A9':['Bahrajn','BH'], '9K':['Kuwejt','KW'], 'HZ':['Arabia Saudyjska','SA'], '7Z':['Arabia Saudyjska','SA'], // Sub-Saharan Africa 'EL':['Liberia','LR'], '5N':['Nigeria','NG'], 'TR':['Gabon','GA'], '9J':['Zambia','ZM'], 'ZS':['RPA','ZA'], 'ZR':['RPA','ZA'], 'ZT':['RPA','ZA'], 'ZU':['RPA','ZA'], '5H':['Tanzania','TZ'], '5Z':['Kenia','KE'], '5X':['Uganda','UG'], '7P':['Lesotho','LS'], 'V5':['Namibia','NA'], 'C9':['Mozambik','MZ'], // North America 'W':['USA','US'], 'K':['USA','US'], 'N':['USA','US'], 'AA':['USA','US'], 'AB':['USA','US'], 'AC':['USA','US'], 'AD':['USA','US'], 'AE':['USA','US'], 'AF':['USA','US'], 'AG':['USA','US'], 'AI':['USA','US'], 'AJ':['USA','US'], 'AK':['USA','US'], 'KL':['Alaska','US'], 'KL7':['Alaska','US'], 'NL7':['Alaska','US'], 'KH6':['Hawaje','US'], 'NH6':['Hawaje','US'], 'VE':['Kanada','CA'], 'VA':['Kanada','CA'], 'VO':['Kanada','CA'], 'VY':['Kanada','CA'], 'CF':['Kanada','CA'], 'CG':['Kanada','CA'], 'CJ':['Kanada','CA'], 'CK':['Kanada','CA'], 'XE':['Meksyk','MX'], 'XF':['Meksyk','MX'], // Central America/Caribbean 'CO':['Kuba','CU'], 'CM':['Kuba','CU'], 'HI':['Dominikana','DO'], 'KP4':['Puerto Rico','PR'], 'NP4':['Puerto Rico','PR'], 'V2':['Antigua','AG'], '8P':['Barbados','BB'], 'J3':['Grenada','GD'], '6Y':['Jamajka','JM'], 'TG':['Gwatemala','GT'], 'TI':['Kostaryka','CR'], 'HP':['Panama','PA'], 'YN':['Nikaragua','NI'], 'HR':['Honduras','HN'], 'YS':['Salwador','SV'], // South America 'PY':['Brazylia','BR'], 'PP':['Brazylia','BR'], 'PQ':['Brazylia','BR'], 'PR':['Brazylia','BR'], 'PS':['Brazylia','BR'], 'PT':['Brazylia','BR'], 'PU':['Brazylia','BR'], 'PV':['Brazylia','BR'], 'PW':['Brazylia','BR'], 'ZV':['Brazylia','BR'], 'ZW':['Brazylia','BR'], 'ZX':['Brazylia','BR'], 'ZY':['Brazylia','BR'], 'ZZ':['Brazylia','BR'], 'LU':['Argentyna','AR'], 'LO':['Argentyna','AR'], 'LP':['Argentyna','AR'], 'LQ':['Argentyna','AR'], 'LR':['Argentyna','AR'], 'LS':['Argentyna','AR'], 'LT':['Argentyna','AR'], 'LV':['Argentyna','AR'], 'LW':['Argentyna','AR'], 'CE':['Chile','CL'], 'CA':['Chile','CL'], 'CB':['Chile','CL'], 'CC':['Chile','CL'], 'CD':['Chile','CL'], 'XQ':['Chile','CL'], 'XR':['Chile','CL'], 'HK':['Kolumbia','CO'], 'HJ':['Kolumbia','CO'], 'YV':['Wenezuela','VE'], 'YW':['Wenezuela','VE'], 'YX':['Wenezuela','VE'], 'OA':['Peru','PE'], 'OB':['Peru','PE'], 'OC':['Peru','PE'], 'CP':['Boliwia','BO'], 'HC':['Ekwador','EC'], 'HD':['Ekwador','EC'], 'CX':['Urugwaj','UY'], 'CV':['Urugwaj','UY'], 'ZP':['Paragwaj','PY'], // Asia 'JA':['Japonia','JP'], 'JE':['Japonia','JP'], 'JF':['Japonia','JP'], 'JG':['Japonia','JP'], 'JH':['Japonia','JP'], 'JI':['Japonia','JP'], 'JJ':['Japonia','JP'], 'JK':['Japonia','JP'], 'JL':['Japonia','JP'], 'JM':['Japonia','JP'], 'JN':['Japonia','JP'], 'JO':['Japonia','JP'], 'JP':['Japonia','JP'], 'JQ':['Japonia','JP'], 'JR':['Japonia','JP'], 'JS':['Japonia','JP'], '7J':['Japonia','JP'], '7K':['Japonia','JP'], '7L':['Japonia','JP'], '7M':['Japonia','JP'], '7N':['Japonia','JP'], '8J':['Japonia','JP'], 'HL':['Korea Płd.','KR'], 'DS':['Korea Płd.','KR'], '6K':['Korea Płd.','KR'], '6L':['Korea Płd.','KR'], '6M':['Korea Płd.','KR'], '6N':['Korea Płd.','KR'], 'BY':['Chiny','CN'], 'BA':['Chiny','CN'], 'BD':['Chiny','CN'], 'BG':['Chiny','CN'], 'BH':['Chiny','CN'], 'BI':['Chiny','CN'], 'BV':['Tajwan','TW'], 'VU':['Indie','IN'], 'AT':['Indie','IN'], 'AU':['Indie','IN'], 'AV':['Indie','IN'], 'AW':['Indie','IN'], 'YB':['Indonezja','ID'], 'YC':['Indonezja','ID'], 'YD':['Indonezja','ID'], 'YE':['Indonezja','ID'], 'YF':['Indonezja','ID'], 'YG':['Indonezja','ID'], 'YH':['Indonezja','ID'], 'EX':['Kirgistan','KG'], 'UN':['Kazachstan','KZ'], 'EY':['Tadżykistan','TJ'], 'EZ':['Turkmenistan','TM'], 'UK':['Uzbekistan','UZ'], 'EK':['Armenia','AM'], '4J':['Azerbejdżan','AZ'], '4L':['Gruzja','GE'], 'HS':['Tajlandia','TH'], 'E2':['Tajlandia','TH'], '9M':['Malezja','MY'], '9V':['Singapur','SG'], 'XV':['Wietnam','VN'], '3W':['Wietnam','VN'], 'XU':['Kambodża','KH'], 'XW':['Laos','LA'], 'XZ':['Mjanma','MM'], 'DU':['Filipiny','PH'], 'DV':['Filipiny','PH'], 'DW':['Filipiny','PH'], 'DX':['Filipiny','PH'], 'DY':['Filipiny','PH'], 'DZ':['Filipiny','PH'], '4D':['Filipiny','PH'], '9M2':['Malezja Zach.','MY'], '9M6':['Malezja Wsch.','MY'], // Oceania 'VK':['Australia','AU'], 'VH':['Australia','AU'], 'VI':['Australia','AU'], 'VJ':['Australia','AU'], 'VL':['Australia','AU'], 'VN':['Australia','AU'], 'VZ':['Australia','AU'], 'ZL':['Nowa Zelandia','NZ'], 'ZK':['Nowa Zelandia','NZ'], 'ZM':['Nowa Zelandia','NZ'], 'KH0':['Mariany Płn.','MP'], 'KH2':['Guam','GU'], // S. Africa/other 'ZS':['RPA','ZA'], }; // Flag from an ISO-3166 alpha-2 code (Unicode regional indicators) - an // algorithm, not an image database, so no need to keep separate files/emoji per country. function _isoToFlag(iso2) { if (!iso2 || iso2.length !== 2) return ''; const codePoints = [...iso2.toUpperCase()].map(c => 127397 + c.charCodeAt(0)); return String.fromCodePoint(...codePoints); } function _countryForCall(call) { const c = (call || '').trim().toUpperCase(); if (!c) return null; const base = c.includes('/') ? c.split('/').reduce((a, b) => (a.length <= b.length ? a : b)) : c; for (let len = 3; len >= 1; len--) { const p = base.slice(0, len); if (_PREFIX_COUNTRY[p]) { const [name, iso2] = _PREFIX_COUNTRY[p]; return { name, flag: _isoToFlag(iso2) }; } } return null; } // "Country" column display mode - DELIBERATELY only one option at a time // (flag OR name), never both at once - a toggle in the Band Activity and // RX Frequency headers (the same global state, two .wj-country-mode-btn // buttons so it works from both panels). let _countryMode = 'flag'; // 'flag' | 'name' function toggleCountryMode() { _countryMode = _countryMode === 'flag' ? 'name' : 'flag'; document.querySelectorAll('.wj-country-mode-btn').forEach(b => { b.textContent = _countryMode === 'flag' ? '🏳️' : 'Aa'; b.title = _countryMode === 'flag' ? 'Kolumna Kraj: flaga (klik: przelacz na nazwe)' : 'Kolumna Kraj: nazwa (klik: przelacz na flage)'; }); _renderDecodes(); _renderRxFreqPanel(); } // QSO automation (UI state, the source of truth is on the backend — // synced via WS auto_seq_status/auto_qso_status) let _autoSeqEnabled = false; let _autoQsoState = 'IDLE'; let _autoQsoPartner = null; // Tracks whether FT8Timer (the Tx Watchdog) is currently armed, so // _updateWatchdogArmed() (defined below, near _hound) only calls // start()/stop() on an actual transition instead of every time an // unrelated broadcast happens to repeat the same enabled state. let _watchdogArmed = false; // ── 15s clock ───────────────────────────────────────────────────────────────── function _startClock() { if (_clockTimer) clearInterval(_clockTimer); _clockTimer = setInterval(_updateClock, 200); } function _updateClock() { const now = new Date(); const ms = now.getUTCMilliseconds(); // FIX (2026-10-20, live question: "czy zegar i pasek postepu sa // synchronizowane") - this clock used to be hardcoded to a fixed 15s // cycle (deliberately left that way when the progress bar below got its // own FT4 fix on 2026-10-17) - correct for FT8, but in FT4 (7.5s) it ran // at HALF the real rate: a full real FT4 period only swept the clock // face halfway, visibly out of sync with the progress bar right below // it. Now shares periodLen/totalSec/cycleSec with the progress bar logic // that follows, instead of computing its own fixed-15s s/frac. const periodLen = _status.mode === 'FT4' ? 7.5 : 15.0; const totalSec = now.getUTCSeconds() + ms / 1000; const cycleSec = totalSec % periodLen; const frac = cycleSec / periodLen; const canvas = document.getElementById('wj-clock'); if (canvas) { const ctx = canvas.getContext('2d'); const cx = 18, cy = 18, r = 14; ctx.clearRect(0, 0, 36, 36); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fillStyle = '#111'; ctx.fill(); ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, -Math.PI/2, -Math.PI/2 + frac * 2 * Math.PI); ctx.closePath(); // Same proportional threshold as the old fixed "s < 8" (8/15), now // scaled to whichever periodLen actually applies. ctx.fillStyle = _status.transmit ? '#c33' : (cycleSec < periodLen * 8 / 15 ? '#1a4a2a' : '#1a2a4a'); ctx.fill(); ctx.beginPath(); ctx.arc(cx, cy, 2, 0, Math.PI*2); ctx.fillStyle = '#fff'; ctx.fill(); } const el = document.getElementById('wj-clock-s'); if (el) el.textContent = Math.floor(cycleSec); // Pasek postepu cyklu UTC (przejety z forka 2026-10-17, #page-wsjtx // ".cycle-bar-wrap" nad wodospadem) - reuses periodLen/totalSec/cycleSec // computed above (shared with the clock face now, see its comment). const pct = (cycleSec / periodLen) * 100; const cycleFill = document.getElementById('wj-cycle-fill'); if (cycleFill) cycleFill.style.width = `${pct.toFixed(1)}%`; const cycleTime = document.getElementById('wj-cycle-time'); if (cycleTime) cycleTime.textContent = `${cycleSec.toFixed(1)}s / ${periodLen.toFixed(1)}s`; const cycleType = document.getElementById('wj-cycle-type'); if (cycleType) cycleType.textContent = `${_status.mode || 'FT8'} (${periodLen.toFixed(1)}s)`; const phaseEl = document.getElementById('wj-cycle-phase'); if (phaseEl) { if (_status.transmit) { phaseEl.textContent = I18n.t('wj_cycle_phase_tx'); phaseEl.style.color = '#ef4444'; } else if (cycleSec < periodLen * 0.84) { phaseEl.textContent = I18n.t('wj_cycle_phase_rx'); phaseEl.style.color = '#4ade80'; } else if (cycleSec < periodLen * 0.96) { phaseEl.textContent = I18n.t('wj_cycle_phase_decoding'); phaseEl.style.color = '#facc15'; } else { phaseEl.textContent = I18n.t('wj_cycle_phase_txprep'); phaseEl.style.color = '#f87171'; } } } // FIX (2026-09-03, live report: "widze usera na FT8 ale inny grid w // makro a inny sie nadaje"): _myCall/_myGrid below were only ever set // ONCE, here in init() at page load. saveProfile() (index.html) updates // window.CurrentUser.callsign after a PROFIL save but NEVER // window.CurrentUser.locator - so changing your own locator mid-session // left this cached _myGrid stale in the macro PREVIEW, while the // backend's auto-QSO engine (which re-reads the account's locator FRESH // every time it actually transmits, see webapp.py ~6882) always used // the correct, just-saved value - the preview and the real transmission // silently disagreed. Factored the derivation out of init() so // saveProfile() can call this after a successful save instead of // requiring a full page reload to pick up the change. function refreshOperatorInfo() { _myCall = S?.callsign || window.CurrentUser?.callsign || ''; _myGrid = window.CurrentUser?.locator || S?.stationLocator || ''; if (!_myCall && window.CurrentUser) { _myCall = window.CurrentUser.callsign || window.CurrentUser.username || ''; } _updateMacroTexts(); } // ── Init ────────────────────────────────────────────────────────────────────── // FIX (2026-10-17, live report: "w ft8 wystarczylo zmienic zakladke to // ciagle wyskakuje udp 2238"): init() runs every time the user switches // INTO the FT8 tab (see ui.js setPage), not just once at app boot - the // monitor-active toast below used to fire unconditionally every single // time, since d.running is true on every check once the (auto-started) // monitor is up. Only worth telling the operator once per session - after // that it's just confirming an already-known, unchanged state. let _monitorActiveToastShown = false; async function init() { refreshOperatorInfo(); _startClock(); try { const r = await fetch('/api/wsjtx/status'); const d = await r.json(); _updateStatus({ running: d.running, decoding: false, transmit: false, }); // Show info in the status const pill = document.getElementById('wj-status-pill'); if (d.running) { if (pill) { pill.textContent = '● ONLINE'; pill.className = 'wsjtx-status-pill online'; } if (!_monitorActiveToastShown) { _monitorActiveToastShown = true; window.UI?.showToast(I18n.t('wj_toast_monitor_active').replace('{port}', d.port)); } } else { // Autostart (wsjtxAutostart in the config, enabled by default) // normally listens on port 2238 by itself - OFFLINE here means // autostart failed (e.g. the port is taken), not that something // needs to be manually clicked (the START button was removed, // listening now always auto-starts). if (pill) { pill.textContent = I18n.t('wj_offline_autostart_failed'); pill.className = 'wsjtx-status-pill'; } } // Show the counters if (d.packets_rx > 0 || d.decodes_rx > 0) { const countEl = document.getElementById('wj-decode-count'); if (countEl) { countEl.removeAttribute('data-i18n'); countEl.textContent = I18n.t('wj_decode_count_session').replace('{n}', d.decodes_rx); } } } catch(e) { console.warn('[wsjtx] init error', e); } await _loadMiniLog(); try { const rr = await fetch('/api/rotator'); const rots = await rr.json(); if (Array.isArray(rots) && rots.length > 0) _onRotatorUpdate(rots[0]); } catch(e) { /* no rotator - the SP/LP buttons stay disabled */ } // Init the scope — retries until the canvas has dimensions (WebGL needs width>0) function _tryScopeInit(attempts) { const c = document.getElementById('wj-scope-canvas'); if (!c) return; const rect = c.getBoundingClientRect(); if (rect.width > 0) { window.WSJTXScope?.init(); } else if (attempts > 0) { setTimeout(() => _tryScopeInit(attempts - 1), 100); } } _tryScopeInit(20); // max 2s of attempts every 100ms _initSplitResizer(); _initHorizontalResizer(); _startRadioSync(); _populateBandSelect(); } // ── API ─────────────────────────────────────────────────────────────────────── async function startWsjtx() { const port = parseInt(document.getElementById('wsjtx-udp-port')?.value || 2237); try { const r = await fetch('/api/wsjtx/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({port}) }); const d = await r.json(); if (d.ok) { _updateStatus({running:true, text:`Nasłuchuję UDP :${port}`}); window.UI?.showToast(I18n.t('wj_toast_monitor_on_port').replace('{port}', port)); } } catch(e) { window.UI?.showToast('✗ ' + e.message, 'error'); } } async function stopWsjtx() { try { await fetch('/api/wsjtx/stop', {method:'POST'}); _updateStatus({running:false}); window.UI?.showToast(I18n.t('wj_toast_monitor_stopped')); } catch(e) {} } async function haltTx() { // Stop FT8 TX — send it to the server and disable it locally try { WS.send({ type: 'ft8_tx_stop' }); await fetch('/api/ft8/halt', {method:'POST'}); } catch(e) {} stopTx(); window.UI?.showToast(I18n.t('wj_toast_tx_stopped')); } function stopTx() { // Local TX abort UI reset. The actual abort is handled server-side // (ft8_tx_stop message -> self._ft8_tx_abort in webapp.py); this just // clears the highlighted macro button. document.querySelectorAll('.wj-tx-btn').forEach(b => b.classList.remove('active')); const btn = document.getElementById('wj-halt-tx-btn'); if (btn) btn.style.background = ''; window.UI?.showToast(I18n.t('wj_toast_tx_paused')); } // ── Our own FT8 RX decoder (instead of a physical WSJT-X/JTDX) ──────────────── let _ownRxEnabled = false; let _radioSyncTimer = null; let _lastSyncedFreq = null; // Syncs the displayed frequency (top bar, wj-freq) with the main radio's // ACTUAL frequency (window.AppState.freq), which is updated globally by // ws.js (telemetry/freq) regardless of whether WSJTX is the active tab. // NOTE: we don't sync wj-mode with S.mode — S.mode is the radio's // OPERATING mode (USB/LSB/CW/...), a different concept from the DIGITAL // mode (FT8/FT4) shown in wj-mode, which is controlled by a separate // decode-mode selector. function _startRadioSync() { if (_radioSyncTimer) clearInterval(_radioSyncTimer); _radioSyncTimer = setInterval(_syncFreqFromRadio, 500); _syncFreqFromRadio(); // an immediate first update, don't wait 500ms } function _syncFreqFromRadio() { if (!S || S.freq == null) return; if (S.freq === _lastSyncedFreq) return; // no change — skip the DOM write _lastSyncedFreq = S.freq; const mhz = (S.freq/1e6).toFixed(6).replace(/(\d+)\.(\d{3})(\d{3})/, '$1.$2.$3'); const el = document.getElementById('wj-freq'); if (el) el.textContent = mhz; } let _decodeMode = 'FT8'; let _lastDxSnr = null; // SNR of the last selected (clicked) DX station — for macro F3 (R+report) // FROZEN report from the backend (partner_report_sent/recv). While a QSO // is active, macros and the UI use THIS value instead of _lastDxSnr (a // raw, changing SNR), so macro == on-air == log. Set in _onAutoQsoStatus. let _frozenRstSent = null; let _frozenRstRcvd = null; // Standard working frequencies (dial freq, Hz) per WSJT-X convention for // FT8 and FT4, for all major amateur bands. Source: WSJT-X's default // "Working Frequencies" table (Settings | Frequencies), matching commonly // published lists (e.g. sigidwiki.com, dxzone.com). Some bands have no // official FT4 convention (e.g. where FT4 is rarely used) — those entries // are omitted instead of guessed. const BAND_FREQUENCIES = [ { band: '160m', ft8: 1840000, ft4: 1840000 }, { band: '80m', ft8: 3573000, ft4: 3575000 }, { band: '60m', ft8: 5357000, ft4: null }, { band: '40m', ft8: 7074000, ft4: 7047500 }, { band: '30m', ft8: 10136000, ft4: 10140000 }, { band: '20m', ft8: 14074000, ft4: 14080000 }, { band: '17m', ft8: 18100000, ft4: 18104000 }, { band: '15m', ft8: 21074000, ft4: 21140000 }, { band: '12m', ft8: 24915000, ft4: 24919000 }, { band: '10m', ft8: 28074000, ft4: 28180000 }, { band: '6m', ft8: 50313000, ft4: 50318000 }, { band: '4m', ft8: 70100000, ft4: null }, { band: '2m', ft8: 144174000, ft4: null }, ]; // FT8/FT4 decode-mode toggle (top bar). Switches the active button, // syncs wj-log-mode (the mode selector in the QSO logging form), and // notifies the backend (ft8_set_decode_mode), which picks the right // encoder/timing (ft8_encoder.py vs ft4_encoder.py, 15s vs 7.5s window). // NOTE: this only switches TX (transmitting). The RX decoding side // (recognizing FT4 signals on air) is NOT implemented yet — that's a // separate, large undertaking requiring a different FFT/sync pipeline. function setDecodeMode(mode) { _decodeMode = mode; const ft8Btn = document.getElementById('wj-mode-ft8-btn'); const ft4Btn = document.getElementById('wj-mode-ft4-btn'); if (ft8Btn) ft8Btn.classList.toggle('active', mode === 'FT8'); if (ft4Btn) ft4Btn.classList.toggle('active', mode === 'FT4'); // Sync the mode selector in the QSO logging form const logModeEl = document.getElementById('wj-log-mode'); if (logModeEl) logModeEl.value = mode; _populateBandSelect(); // the frequency list depends on the mode (FT8 vs FT4) window.WSJTXScope?.setScopeDecodeMode(mode); window.WS?.send({ type: 'ft8_set_decode_mode', mode }); window.UI?.showToast(I18n.t('wj_toast_decode_mode').replace('{mode}', mode)); } // Fills the band