feat: panadapter spots that read, a CI-V link that survives, no auto-call

Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.

"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.

And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.

CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.

Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.

Also:
  - the log rotates while running, not only at startup: the CI-V trace left on
    wrote 416 MB and nothing would have stopped it before the disk did. Closing
    it now releases the crash file too — the runtime keeps its own duplicate.
  - the interface zoom announces itself, with a badge, a click back to 100% and
    a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
  - no more elastic bounce, and no swipe-to-navigate out of the app.
  - Edit QSO: your own TX power and the contacted station's extended locator
    were saved and written back with no box to set them.
  - FT decodes: continents are a multiple choice; a compound MSHV message that
    answers two stations in one line is recognised as addressed to you.
This commit is contained in:
2026-08-23 01:16:26 +02:00
parent bcfb3bfd37
commit f50806fbd9
17 changed files with 829 additions and 209 deletions
+7 -91
View File
@@ -209,7 +209,6 @@ type SectionId =
| 'databases'
| 'awards'
| 'cat'
| 'ftx'
| 'rotator'
| 'winkeyer'
| 'antenna'
@@ -314,7 +313,6 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.ftx'), id: 'ftx' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
@@ -343,7 +341,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', ftx: 'sec.ftx', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
adifmon: 'sec.adifmon',
foldersync: 'sec.foldersync',
webpublish: 'sec.webpublish',
@@ -1801,7 +1799,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// form: waiting for the modal's Save to see a colour on the waterfall would
// make picking one a guessing game.
type SpotColor = { text: string; bg?: string };
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor> }>({ enabled: true, colors: {} });
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor & { hide?: boolean }> }>({ enabled: true, colors: {} });
useEffect(() => { GetSpotColors().then((c: any) => setSpotColors(c ?? { enabled: true, colors: {} })).catch(() => {}); }, []);
const saveSpotColors = (next: { enabled: boolean; colors: Record<string, SpotColor> }) => {
setSpotColors(next);
@@ -4783,91 +4781,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setEditingServer(next);
}
// FTx decodes: what OpsLog does on its own with the digital stream.
//
// Auto-call KEYS THE TRANSMITTER without anyone clicking, so the panel is
// deliberately explicit about it: off by default, every criterion opt-in, and
// a plain statement of what the machine will do once it is on.
function FtxPanel() {
const set = (patch: Partial<AutoCallSettings>) => {
const next = { ...autoCall, ...patch };
setAutoCall(next);
writeUiPref(autoCallKey, JSON.stringify(next));
};
const crit = (which: 'criteria' | 'watchCriteria', k: keyof AutoCallCriteria) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox
checked={!!autoCall[which][k]}
onCheckedChange={(v) => set({ [which]: { ...autoCall[which], [k]: !!v } } as any)}
/>
{t(`ftx.c_${k}`)}
</label>
);
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'bandmode', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
return (
<>
<SectionHeader title={t('sec.ftx')} hint={t('ftx.hint')} />
<div className="space-y-4">
<div className="rounded-md border border-border p-3 space-y-3">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={autoCall.enabled} className="mt-0.5"
onCheckedChange={(v) => set({ enabled: !!v })} />
<span>
{t('ftx.enable')}{' '}
<span className="text-xs text-muted-foreground">{t('ftx.enableHint')}</span>
</span>
</label>
{autoCall.enabled && (
<div className="pl-6 space-y-3">
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{t('ftx.callWhen')}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('criteria', k))}</div>
</div>
<div className="border-t border-border/60 pt-3">
<p className="text-xs font-semibold text-muted-foreground mb-1">{t('ftx.watch')}</p>
<p className="text-[11px] text-muted-foreground mb-1.5 leading-relaxed">{t('ftx.watchHint')}</p>
<Textarea
className="h-20 font-mono text-xs"
placeholder={"4S7*\nTM0HQ\n*/P"}
defaultValue={(autoCall.watch ?? []).join('\n')}
key={`w-${(autoCall.watch ?? []).length}`}
onBlur={(e) => set({
watch: e.target.value.split('\n').map((x) => x.trim().toUpperCase()).filter(Boolean),
})}
/>
{(autoCall.watch ?? []).length > 0 && (
<div className="mt-2">
<p className="text-[11px] text-muted-foreground mb-1">{t('ftx.watchOnlyIf')}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('watchCriteria', k))}</div>
</div>
)}
</div>
<div className="border-t border-border/60 pt-3 flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('ftx.cooldown')}</span>
<Input type="number" min={10} max={3600} className="w-24 h-7 text-xs"
defaultValue={autoCall.cooldownSec}
key={`cd-${autoCall.cooldownSec}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 10 && v <= 3600) set({ cooldownSec: v });
}} />
<span className="text-xs text-muted-foreground">s</span>
</div>
<p className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
{t('ftx.warn')}
</p>
</div>
)}
</div>
</div>
</>
);
}
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of
@@ -7233,7 +7146,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
ftx: FtxPanel,
udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either
@@ -7343,7 +7255,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const c = spotColors.colors[k] ?? { text: '', bg: '' };
return (
<div key={k} className="flex items-center gap-3 px-3 py-1.5">
<span className="text-xs flex-1 min-w-0">{t('spotcol.s_' + k)}</span>
{/* Sent to the radio at all. First in the row because it
decides whether the colours next to it mean anything. */}
<Checkbox checked={!c.hide} title={t('spotcol.send')}
onCheckedChange={(v) => setSpotColor(k, { hide: !v } as any)} />
<span className={cn('text-xs flex-1 min-w-0', c.hide && 'opacity-40 line-through')}>{t('spotcol.s_' + k)}</span>
{/* The preview is the point of the row: two colour boxes say
nothing about what the pair looks like together. */}
<span className="text-xs font-mono px-2 py-0.5 rounded"