/**
* 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