180 lines
7.1 KiB
TypeScript
180 lines
7.1 KiB
TypeScript
// Auto-call: let OpsLog answer a decode without the operator clicking it.
|
|
//
|
|
// 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
|
|
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, 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;
|
|
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<string, number>;
|
|
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');
|
|
// Only a CQ. Answering a station mid-QSO is both rude and futile — WSJT-X
|
|
// actions a Reply only for CQ and QRZ anyway.
|
|
if (!d.cq) return no('not a CQ');
|
|
// 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');
|
|
}
|