feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO announces the station on the master cluster — the QSO's own station callsign as the DX, on the frequency the contact was made on — so callers find the run without waiting for someone else to spot it. The node fills the DE field from the login, so the spotter is us too: a self-spot. It fires on the FIRST QSO of a frequency and then at most once per interval. Both halves matter: announcing every QSO would flood the node and get the station filtered out, while a pure timer would stay silent for minutes after a band change. A drift of up to 500 Hz still counts as the same run, so nudging the VFO mid-pileup does not re-announce. Five minutes is the floor, clamped in SaveSelfSpotSettings as well as in the input: the limit protects the node from us, so it must not depend on the frontend. The interval input keeps raw text and clamps on blur — clamping per keystroke rewrote "10" to "5" as soon as the "1" landed. Wired into both log paths (manual entry and UDP auto-log) on the async side, so a cluster that is slow or down never holds up logging. A send failure restores the previous throttle state, so the next QSO retries instead of sitting out an interval that produced no spot.
This commit is contained in:
@@ -26,7 +26,7 @@ import {
|
||||
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
|
||||
AudioStartTX, AudioStopTX, AudioTXActive,
|
||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect, GetSelfSpotSettings, SaveSelfSpotSettings,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
@@ -1524,19 +1524,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
|
||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||
// Self-spot. SELF_SPOT_MIN_MIN mirrors the backend floor — the input clamps on
|
||||
// blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
|
||||
// the "1" landed and the field would fight the operator.
|
||||
const SELF_SPOT_MIN_MIN = 5;
|
||||
const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN });
|
||||
const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN));
|
||||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||||
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
|
||||
|
||||
async function reloadClusterServers() {
|
||||
try {
|
||||
const [list, ac, st] = await Promise.all([
|
||||
const [list, ac, st, ss] = await Promise.all([
|
||||
ListClusterServers(),
|
||||
GetClusterAutoConnect(),
|
||||
GetClusterStatus(),
|
||||
GetSelfSpotSettings(),
|
||||
]);
|
||||
setClusterServers((list ?? []) as ClusterServer[]);
|
||||
setClusterAutoConnectState(ac);
|
||||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||||
setSelfSpot(ss as any);
|
||||
setSelfSpotText(String((ss as any).minutes ?? SELF_SPOT_MIN_MIN));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -1849,6 +1858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
await SetClusterAutoConnect(clusterAutoConnect);
|
||||
await SaveSelfSpotSettings(selfSpot as any);
|
||||
|
||||
setMsg('Settings saved.');
|
||||
onSaved();
|
||||
@@ -4114,6 +4124,39 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
|
||||
</label>
|
||||
|
||||
{/* Self-spot. The interval only shows once it's on — an interval for
|
||||
something switched off is just a question the operator can't act on. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={selfSpot.enabled}
|
||||
onCheckedChange={(c) => setSelfSpot((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('clu.selfSpot')}
|
||||
</label>
|
||||
{selfSpot.enabled && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('clu.selfSpotEvery')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={SELF_SPOT_MIN_MIN}
|
||||
max={240}
|
||||
className="w-20 h-8 font-mono text-xs"
|
||||
value={selfSpotText}
|
||||
onChange={(e) => setSelfSpotText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.floor(Number(selfSpotText));
|
||||
const v = Number.isFinite(n) && n > SELF_SPOT_MIN_MIN ? Math.min(n, 240) : SELF_SPOT_MIN_MIN;
|
||||
setSelfSpotText(String(v));
|
||||
setSelfSpot((s) => ({ ...s, minutes: v }));
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{t('clu.selfSpotMinutes')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('clu.selfSpotHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingServer && (
|
||||
|
||||
Reference in New Issue
Block a user