/**
* cw.js (frontend) — CW keying panel
* Keying method: DTR or RTS on a COM port (CAT/cmd-17 retired 2026-08-26,
* see the cw_empty_ptt project memory).
*
* MACROS: editable by every user, in their own scope — saved in the
* browser's localStorage (per-user/per-device, no effect on other
* operators or server config). Double-click on a macro card opens editing
* (label + CW text); F1-F15 sends the macro.
*/
(function () {
'use strict';
const S = window.AppState;
const MACROS_KEY = 'cwMacros';
let macros = [];
let cwActive = false;
let cwMethod = 'dtr';
// Default macros (used on first run / when nothing is saved in localStorage)
const DEFAULT_MACROS = [
{ id: 1, label: 'CQ', text: 'CQ CQ DE {MYCALL} {MYCALL} K' },
{ id: 2, label: 'RST', text: 'UR 599 599 BK' },
{ id: 3, label: '73', text: '73 DE {MYCALL} SK' },
{ id: 4, label: 'QRZ?', text: 'QRZ? DE {MYCALL}' },
{ id: 5, label: 'AGN?', text: 'PSE AGN?' },
{ id: 6, label: 'QSL', text: 'TU QSL 73' },
];
// ── Load macros (localStorage, per-user/per-browser) and status ─────────────
async function loadMacros() {
try {
const token = localStorage.getItem('token') || '';
const r = await fetch('/api/cw/macros', {
headers: token ? {'Authorization': `Bearer ${token}`} : {}
});
if (r.ok) {
const data = await r.json();
macros = data.macros || DEFAULT_MACROS.map(m => ({...m}));
} else {
macros = DEFAULT_MACROS.map(m => ({...m}));
}
} catch(e) {
macros = DEFAULT_MACROS.map(m => ({...m}));
}
// Fill in missing ids (1-6) with defaults
for (const def of DEFAULT_MACROS) {
if (!macros.find(m => m.id === def.id)) macros.push({...def});
}
renderMacros();
}
async function loadStatus() {
try {
const token = localStorage.getItem('token') || '';
const r = await fetch('/api/cw/status', {
headers: token ? {'Authorization': `Bearer ${token}`} : {}
});
const st = await r.json();
cwMethod = st.method || 'dtr';
updateMethodBadge();
} catch(e) {}
}
function updateMethodBadge() {
const el = document.getElementById('cw-method-badge');
if (!el) return;
el.textContent = cwMethod.toUpperCase();
el.style.color = 'var(--amber)';
}
// ── Render macros ────────────────────────────────────────────────────────────
function renderMacros() {
const el = document.getElementById('cw-macros-grid');
if (!el) return;
// Only update the labels/text of existing cards (the 2×3 grid is static in the HTML)
const display = macros.slice(0, 6);
display.forEach(m => {
const lbl = document.getElementById('cwm-lbl-' + m.id);
const txt = document.getElementById('cwm-txt-' + m.id);
const card = document.getElementById('cwm-' + m.id);
if (lbl) lbl.textContent = m.label || ('F' + m.id);
if (txt) txt.textContent = m.text || '—';
if (card) {
card.title = m.text || '';
card.classList.toggle('empty', !m.text);
}
});
}
function startEdit(id) {
const m = macros.find(x => x.id === id);
if (!m) return;
const modal = document.getElementById('cw-edit-modal');
if (!modal) return;
document.getElementById('cw-edit-id').value = id;
document.getElementById('cw-edit-label').value = m.label;
document.getElementById('cw-edit-text').value = m.text;
document.getElementById('cw-edit-title').textContent = `Makro F${id}`;
modal.style.display = 'flex';
document.getElementById('cw-edit-text').focus();
}
function saveEditMacro() {
const id = parseInt(document.getElementById('cw-edit-id')?.value);
const label = document.getElementById('cw-edit-label')?.value?.trim() || `F${id}`;
const text = document.getElementById('cw-edit-text')?.value?.trim();
const idx = macros.findIndex(m => m.id === id);
if (idx >= 0) { macros[idx].label = label; macros[idx].text = text; }
closeEdit();
_saveMacros();
renderMacros();
}
async function _saveMacros() {
try {
const token = localStorage.getItem('token') || '';
await fetch('/api/cw/macros', {
method: 'POST',
headers: {'Content-Type': 'application/json',
...(token ? {'Authorization': `Bearer ${token}`} : {})},
body: JSON.stringify({ macros }),
});
} catch(e) {
console.warn('[cw] macro save failed:', e);
window.UI?.showToast('✗ Nie udało się zapisać makra', 'error');
}
}
function closeEdit() {
const m = document.getElementById('cw-edit-modal');
if (m) m.style.display = 'none';
}
// ── Sending ───────────────────────────────────────────────────────────────────
async function sendMacro(id) {
const m = macros.find(x => x.id === id);
if (m?.text) await sendText(m.text);
}
async function sendText(text, extra = {}) {
if (cwActive) { stopCW(); return; }
// FIX (reported live 2026-08-24): cwActive used to flip true only once
// the 'cw_sending' WS broadcast echoed back from the server. A repeat
// tap/keypress fired BEFORE that echo arrived (any real network RTT -
// worse on mobile/LTE) still saw cwActive===false, fell through to a
// brand-new /api/cw/send instead of the stop-toggle above, and the
// server's own overlap guard (_cw_tx_busy in webapp.py) silently
// rejected it - looked exactly like "the macro just does nothing" on
// 3 out of 4 rapid presses, with no obvious sign why. Setting it here,
// optimistically, closes that window: any repeat tap while our own
// request is in flight now correctly stops it instead of racing a
// second send.
cwActive = true;
// FIX (reported live 2026-08-24: "cw makro sie zwiesilo, w ogole nie
// reaguje" - every tap after this just re-sent /api/cw/stop with no
// effect). The optimistic cwActive=true above has no recovery path if
// the fetch itself throws (dropped connection, server restart mid-
// request) - the old code had no try/catch here, so an exception left
// cwActive stuck true FOREVER: every later tap saw cwActive===true and
// called stopCW() instead of sending, forever, even though nothing was
// actually transmitting server-side. A safety timeout (below) also
// guards the case where the request itself succeeds but the
// 'cw_done'/'cw_stopped'/'cw_error' WS confirmation never arrives (a
// dropped WS reconnect mid-transmission) - self-heals either way
// instead of requiring a page reload.
const _cwSafetyTimer = setTimeout(() => {
if (cwActive) {
cwActive = false;
console.warn('[cw] cwActive force-cleared by safety timeout (no cw_done/error arrived)');
}
}, 20000); // generous: even a long macro at low WPM finishes well under this
try {
// {CALL} and {RST} used to be filled in from the "Quick QSO log" form
// (removed). They remain as placeholders for the user to type manually
// into the macro text (e.g. "UR 599 OP JAN") — {MYCALL} still works
// automatically from the station settings.
const vars = {
myCall: S.callsign || '',
rst: '599',
...extra,
};
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token') || '';
const r = await fetch('/api/cw/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? {'Authorization': `Bearer ${token}`} : {}),
},
body: JSON.stringify({ text, vars }),
});
if (r.status === 401) {
cwActive = false;
clearTimeout(_cwSafetyTimer);
window.UI?.showToast('✗ CW: brak autoryzacji — zaloguj się ponownie', 'error');
return;
}
const res = await r.json();
if (!res.ok) {
// The send never actually started (rejected before any PTT/keying) -
// undo the optimistic flag so the button/Escape-to-stop don't get
// stuck thinking a transmission is running when none is.
cwActive = false;
clearTimeout(_cwSafetyTimer);
window.UI?.showToast('✗ CW: ' + (res.error || 'błąd'), 'error');
}
// res.ok === true: DELIBERATELY leave cwActive=true and the safety
// timer RUNNING here - the fetch resolving successfully only means
// the server accepted and finished the transmission (it blocks until
// done), not that we've received the 'cw_done' WS confirmation that
// normally clears cwActive (handleWS below). If that WS message gets
// lost (a reconnect mid-transmission), the timer is the only thing
// that will ever clear it - clearing it here unconditionally would
// remove that backstop in exactly the case it exists for.
} catch (e) {
cwActive = false;
clearTimeout(_cwSafetyTimer);
window.UI?.showToast('✗ CW: błąd połączenia — ' + e.message, 'error');
}
}
async function sendCustom() {
const inp = document.getElementById('cw-custom-input');
if (!inp?.value.trim()) return;
await sendText(inp.value);
inp.value = '';
}
async function stopCW() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token') || '';
await fetch('/api/cw/stop', {
method: 'POST',
headers: token ? {'Authorization': `Bearer ${token}`} : {},
});
}
// The WPM slider in the CW KEYER panel (#cw-wpm-slider) controls CI-V
// KEYSPD (14 0C) — the same mechanism as the sliders in the left column
// (rig_slider). Step of 1 WPM. The initial value and live-sync (change on
// the radio's own panel) are handled by RadioFunctions (level_keyspd in
// _sliderEls, see radiofunctions.js).
function setWPM(wpm) {
const val = parseInt(wpm);
const el = document.getElementById('cw-wpm-val');
if (el) el.textContent = val;
WS.send({ type: 'rig_slider', id: 'level_keyspd', value: val });
}
async function setMethodUI(method) {
cwMethod = method;
updateMethodBadge();
await fetch('/api/cw/method', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method }),
});
window.UI?.showToast(`CW metoda: ${method.toUpperCase()}`);
}
async function openDTRPort(port) {
const r = await fetch('/api/cw/dtr-port', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
});
const res = await r.json();
window.UI?.showToast(res.ok ? `✓ Port DTR/RTS: ${port}` : `✗ ${res.error}`, res.ok ? 'info' : 'error');
}
// ── WS handler ────────────────────────────────────────────────────────────────
function handleWS(msg) {
if (msg.type === 'cw_sending') {
cwActive = true;
const st = document.getElementById('cw-status');
if (st) {
st.innerHTML = `▶ TX [${msg.method||''}] ${esc(msg.text||'')}`;
}
document.querySelectorAll('.cw-send-btn').forEach(b => b.classList.add('cw-tx'));
}
if (msg.type === 'cw_done' || msg.type === 'cw_stopped') {
cwActive = false;
const st = document.getElementById('cw-status');
if (st) { st.innerHTML = I18n.t('cw_status_ready'); st.style.color = 'var(--dim)'; }
document.querySelectorAll('.cw-send-btn').forEach(b => b.classList.remove('cw-tx'));
}
if (msg.type === 'cw_error') {
cwActive = false;
const st = document.getElementById('cw-status');
if (st) { st.textContent = '✗ ' + (msg.error||''); st.style.color = 'var(--red)'; }
document.querySelectorAll('.cw-send-btn').forEach(b => b.classList.remove('cw-tx'));
}
}
// ── F1-F15 keys ───────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const f = e.key?.match(/^F(\d+)$/);
if (!f) return;
const id = parseInt(f[1]);
if (id >= 1 && id <= 15) {
e.preventDefault();
const m = macros.find(x => x.id === id);
if (m?.text) sendMacro(id);
}
// Escape = stop
if (e.key === 'Escape' && cwActive) { e.preventDefault(); stopCW(); }
});
function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>'); }
window.CW = {
loadMacros, loadStatus, renderMacros,
sendMacro, sendText, sendCustom, stopCW, setWPM, setMethodUI, openDTRPort,
handleWS, startEdit, saveEditMacro, closeEdit,
};
window.CW._loaded = false;
document.addEventListener('DOMContentLoaded', () => {
loadStatus();
window.CW._loaded = true;
});
// Load macros (and re-fetch status with the token) after login — that's
// when we finally have the token and uid. The DOMContentLoaded call above
// runs pre-login without a token and always gets the default, so the
// method badge needs this second call to show the real configured method.
window.addEventListener('app:ready', () => { loadMacros(); loadStatus(); });
})();