From 19c5045dc61ee393422ffac2413ce29eccc5bf61 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 27 Jun 2026 20:28:29 +0200 Subject: [PATCH] fix: autocall in remote not working properly --- app.go | 51 +++++++++++++++++-- frontend/src/App.tsx | 59 ++++++++++++++++++---- frontend/src/components/RecentQSOsGrid.tsx | 2 + 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/app.go b/app.go index 1acb5e4..8ea4326 100644 --- a/app.go +++ b/app.go @@ -4207,13 +4207,30 @@ func (a *App) saveQSORecording(q *qso.QSO) { return } applog.Printf("qso-rec: saved %s", path) - // Auto-send the recording once the file exists. - if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" { - _ = a.sendRecordingEmail(qc, path) + // Auto-send the recording once the file exists. Give the operator visible + // feedback either way — silently skipping (no e-mail known) was confusing. + if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend { + if strings.TrimSpace(qc.Email) == "" { + applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign) + a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign)) + } else if err := a.sendRecordingEmail(qc, path); err != nil { + a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign)) + } else { + a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign)) + a.markRecordingSent(qc.ID) + } } }() } +// toast emits a transient message the frontend shows as a toast notification. +// Used for background events (auto-send results) the UI can't otherwise surface. +func (a *App) toast(msg string) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "toast", msg) + } +} + // sanitizeFilename makes a callsign safe for a filename (slashes etc.). func sanitizeFilename(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) @@ -4687,6 +4704,28 @@ func (a *App) fillTemplate(tmpl string, q qso.QSO) string { return r.Replace(tmpl) } +// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio +// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a +// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent +// status update (eQSL/upload) isn't clobbered by writing back the whole row. +func (a *App) markRecordingSent(id int64) { + if a.qso == nil || id == 0 { + return + } + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + applog.Printf("qso-rec: mark sent: load %d: %v", id, err) + return + } + if q.Extras == nil { + q.Extras = map[string]string{} + } + q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02") + if err := a.qso.Update(a.ctx, q); err != nil { + applog.Printf("qso-rec: mark sent: update %d: %v", id, err) + } +} + // sendRecordingEmail e-mails a QSO recording to the contacted operator. func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { s, _ := a.GetEmailSettings() @@ -4732,7 +4771,11 @@ func (a *App) SendQSORecordingEmail(id int64) error { if _, e := os.Stat(path); e != nil { return fmt.Errorf("recording file missing: %s", name) } - return a.sendRecordingEmail(q, path) + if err := a.sendRecordingEmail(q, path); err != nil { + return err + } + a.markRecordingSent(id) + return nil } // ── ClubLog Country File (cty.xml) exceptions ───────────────────────── diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0943cf6..b515974 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -206,6 +206,16 @@ function rstCategory(mode: string): keyof RSTLists { if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; return 'phone'; } +// estimateCwMs roughly estimates how long the resolved text takes to send in CW +// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm +// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can +// lag by tens of seconds over a remote/serial-over-IP link. +function estimateCwMs(resolved: string, wpm: number): number { + const w = Math.max(5, wpm || 25); + const chars = resolved.replace(/\s/g, '').length; + const spaces = (resolved.match(/\s/g) || []).length; + return ((chars * 10 + spaces * 4) * 1200) / w; +} // rstOptions returns the valid report choices for a mode from the user's // editable lists (Settings → Modes), with a tiny fallback before they load. function rstOptions(mode: string, lists: RSTLists): string[] { @@ -1059,6 +1069,12 @@ export default function App() { return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; }, [refresh]); + // Backend-emitted toast messages (e.g. recording auto-send result/skip). + useEffect(() => { + const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); }); + return () => { off(); }; + }, [showToast]); + // Poll PstRotator for the live antenna heading (status bar). Cheap when the // rotator is disabled (the backend just reads settings and returns). useEffect(() => { @@ -1246,12 +1262,20 @@ export default function App() { // source behaves identically. function handleSpotClick(s: any) { const m = inferSpotMode(s.comment ?? '', s.freq_hz); - if (catState.connected) { - tuneRigCAT(s.freq_hz, m); - } else { - setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); - if (s.band) setBand(s.band); + // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic), + // then tune the rig if CAT is connected. We must not wait for the CAT status + // echo to update the display: on a remote Flex link that echo lags by seconds, + // so the frequency used to only catch up once you nudged the VFO. noteManualEdit() + // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't + // revert the optimistic value before the new freq is reported back. + if (s.freq_hz && s.freq_hz > 0) { + const mhz = (s.freq_hz / 1_000_000).toFixed(5); + setFreqMhz(mhz); + setRxFreqMhz(mhz); + noteManualEdit(); } + if (s.band) setBand(s.band); + if (catState.connected) tuneRigCAT(s.freq_hz, m); if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call, { force: true }); applySpotPOTA((s as any).pota_ref); @@ -1576,15 +1600,22 @@ export default function App() { } async function wkSend(rawText: string) { setWkSent(''); + const resolved = resolveCW(rawText); const doLog = //i.test(rawText); // resolveCW strips the token (unknown var → "") - await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); + await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e))); // (e.g. "BK 73 TU ") logs the contact AFTER the keyer has - // finished sending — wait for the busy flag to rise then fall, so the QSO - // isn't logged (and the form cleared) while the CW is still going out. + // finished sending — so the QSO isn't logged (and the form cleared) while CW + // is still going out. We'd like to wait for the busy flag to rise then fall, + // but over a remote/serial-over-IP link that status echo can lag by tens of + // seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED + // send duration (text length × WPM) plus slack: log when busy clears OR the + // estimate elapses, whichever comes first. if (!doLog) return; const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); + const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start - for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish + const deadline = Date.now() + capMs; + while (wkBusyRef.current && Date.now() < deadline) await sleep(50); void save(); } // stopAutoCall cancels any running auto-call loop. @@ -1599,8 +1630,14 @@ export default function App() { const m = wkMacros[i]; if (!m) break; await wkSend(m.text); - for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start - for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish + // Wait for the message to finish before the gap+resend. Cap the wait at the + // ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP + // link the keyer's "busy" status lags badly and stays stuck true for tens of + // seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead. + const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; + for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start + const deadline = Date.now() + capMs; + while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); if (gen !== autoCallGenRef.current) break; await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call } diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index c3ad4ef..3e4b3d5 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -157,6 +157,8 @@ export const COL_CATALOG: ColEntry[] = [ { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. { group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, + // App-specific: when the QSO's audio recording was e-mailed to the station. + { group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, // ── Uploads (online logbooks) ── // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,