// Auto-call: let OpsLog answer a decode without the operator clicking it. // // WITHDRAWN FROM THE INTERFACE, because DXHunter already does it. // // Two programs from the same shack deciding on their own to answer the same // decode is worse than either doing it alone: they cannot see each other, so // they key over one another, and afterwards there is no telling which of them // called. The duplicate is the one to remove, and DXHunter is where this lives. // // There is no switch in Preferences and no button on the decodes toolbar, and // App.tsx returns before the loop can run. The file is kept whole: the rules // below are the delicate part, argued over and tested, and rewriting them from // memory later would be worse than leaving them here. Reinstating the feature // means restoring all three — the settings page, the button, and the guard. // // This KEYS THE TRANSMITTER on its own, which is why the rules here are written // as a series of refusals rather than a search for a reason to call. Everything // below has to be true; anything unknown means no. // // The decision is made here, in one pure function, precisely because it is the // dangerous part: it can be read, argued with and tested without a radio. export type AutoCallCriteria = { dxcc: boolean; // entity never worked bandmode: boolean; // entity worked, but neither this band nor this mode band: boolean; // entity never worked on this band mode: boolean; // entity never worked in this mode slot: boolean; // band and mode each worked, never together grid: boolean; // square wanted under the grid scope county: boolean; // US county never worked pota: boolean; // park never worked // No SOTA here, though the shape invites it: a decode carries no summit // reference and the backend publishes no "new summit" flag, so a criterion // for it could never be true. It WAS declared, translated and impossible to // tick — a field that lies about what the feature can do. pfx: boolean; // CQ WPX prefix never worked }; export type AutoCallSettings = { enabled: boolean; criteria: AutoCallCriteria; // Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still // subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is // the request, not "call it every time it appears". watch: string[]; // Empty means call a watched callsign whenever it is not already worked. watchCriteria: AutoCallCriteria; // Seconds to ignore a callsign after calling it, so a station that keeps // sending CQ is not re-answered every slot while the QSO is in progress. cooldownSec: number; }; export const emptyCriteria: AutoCallCriteria = { dxcc: false, bandmode: false, band: false, mode: false, slot: false, grid: false, county: false, pota: false, pfx: false, }; export const defaultAutoCall: AutoCallSettings = { // OFF, and it stays off until asked for. Unattended transmit is not something // to inherit from an upgrade. enabled: false, criteria: { ...emptyCriteria }, watch: [], watchCriteria: { ...emptyCriteria }, cooldownSec: 120, }; const AC_KEY = 'opslog.autoCall'; export function loadAutoCall(): AutoCallSettings { try { const raw = localStorage.getItem(AC_KEY); if (!raw) return { ...defaultAutoCall }; const v = JSON.parse(raw); return { ...defaultAutoCall, ...v, criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) }, watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) }, watch: Array.isArray(v?.watch) ? v.watch : [], }; } catch { return { ...defaultAutoCall }; } } export const autoCallKey = AC_KEY; // A decode's resolved novelty, the same shape the panel already renders from. export type DecodeStatus = { status?: string; worked_call?: boolean; new_grid?: boolean; grid_state?: string; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; }; // matchesWildcard is the same rule the alert filters use: * is any run, ? is one. export function matchesWildcard(pattern: string, call: string): boolean { const p = pattern.trim().toUpperCase(); const c = call.trim().toUpperCase(); if (!p) return false; const re = new RegExp('^' + p.split('').map((ch) => ( ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') )).join('') + '$'); return re.test(c); } // anyCriterion is false for an all-off set, which is what makes "watch this // callsign, no conditions" expressible. function anyCriterion(c: AutoCallCriteria): boolean { return Object.values(c).some(Boolean); } // meets reports whether a decode satisfies at least one ticked criterion. function meets(c: AutoCallCriteria, e: DecodeStatus): boolean { if (c.dxcc && e.status === 'new') return true; // Each status is exclusive, so a station that is new on both counts matches // ONLY this criterion — ticking "new band" alone would not catch it, which is // the wrong way round: it is the better catch of the two. if (c.bandmode && e.status === 'new-band-mode') return true; if (c.band && e.status === 'new-band') return true; if (c.mode && e.status === 'new-mode') return true; if (c.slot && e.status === 'new-slot') return true; // A square that is merely UNCONFIRMED is not called: the QSO is already made, // and calling again would work a duplicate to chase a QSL. if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true; if (c.county && e.new_county) return true; if (c.pota && e.new_pota) return true; if (c.pfx && e.new_pfx) return true; return false; } export type AutoCallDecode = { call: string; cq?: boolean; msg?: string; instance?: string; }; // shouldAutoCall decides whether to answer one decode. The reason is returned // for the log: an automatic transmission with no record of WHY is the thing an // operator cannot argue with after the fact. export function shouldAutoCall( s: AutoCallSettings, d: AutoCallDecode, e: DecodeStatus | undefined, opts: { // busy is "some receiver is mid-QSO", NOT "this one is transmitting". // // The distinction is the whole bug it fixes: with two instances the caller // used to test a single global transmit flag, which belonged to whichever // receiver reported last. So while slice A worked a station, slice B looked // idle and auto-call started another QSO on it — and the moment either // finished it chained straight into the next. One station at a time means // one across ALL receivers, not one per receiver. busy: boolean; calledAt: Map; now: number; myCall?: string; }, ): { call: boolean; reason: string } { const no = (why: string) => ({ call: false, reason: why }); if (!s.enabled) return no('off'); if (!e) return no('status not resolved yet'); const call = (d.call ?? '').trim().toUpperCase(); if (!call) return no('no callsign'); // Never answer ourselves, however the decode reached us. if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign'); // NOT limited to a CQ, deliberately. // // It used to be, on the reasoning that answering a station mid-QSO is calling // over somebody. That reasoning ignored the case the feature exists for: a // DXpedition running a pileup never sends CQ at all — it works caller after // caller — so the rule sat out the one contact auto-call was turned on for. A // new entity on 15 m FT8, decode after decode, and not a single transmission. // // WHEN to transmit is not ours to decide either: the Reply goes to the // decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct // behaviours, both belonging to the program that owns the timing. Here the // question is only whether the station is one the operator wants — the // criteria below answer that, and the cooldown and the busy check keep it from // calling twice. // Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it // has not finished with. Starting a second exchange before the first is done // is what turned this into a machine that called without stopping. if (opts.busy) return no('a QSO is already in progress'); const last = opts.calledAt.get(call); if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently'); // The watch list first: an explicitly named station outranks the general // criteria, and may carry conditions of its own. const watched = s.watch.some((p) => matchesWildcard(p, call)); if (watched) { if (!anyCriterion(s.watchCriteria)) { // No conditions attached: call it unless it is already worked. return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' }; } return meets(s.watchCriteria, e) ? { call: true, reason: 'watch list + criteria' } : no('watched, but no criterion met'); } if (!anyCriterion(s.criteria)) return no('no criteria ticked'); return meets(s.criteria, e) ? { call: true, reason: 'criteria' } : no('no criterion met'); }