feat: Winkeyer

This commit is contained in:
2026-06-02 01:17:26 +02:00
parent 2eb77370e4
commit 2b4326b553
26 changed files with 3125 additions and 645 deletions
+247 -13
View File
@@ -11,12 +11,13 @@ import {
GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
TestLoTWUpload, ListTQSLStationLocations,
@@ -146,6 +147,7 @@ type SectionId =
| 'awards'
| 'cat'
| 'rotator'
| 'winkeyer'
| 'antenna'
| 'audio';
@@ -172,8 +174,7 @@ const TREE: TreeNode[] = [
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' },
{ kind: 'item', label: 'Database location', id: 'database' },
{ kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
@@ -181,6 +182,7 @@ const TREE: TreeNode[] = [
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
],
@@ -199,11 +201,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database location',
database: 'Database',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)',
antenna: 'Antenna',
audio: 'Audio devices',
};
@@ -284,6 +287,21 @@ function SectionHeader({ title, hint }: { title: string; hint?: string }) {
);
}
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
return (
<div className="-mt-2 mb-4">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
<User className="size-3.5" />
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
switch profiles to edit another identity.
</span>
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -340,17 +358,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
type WKSettings = {
enabled: boolean; engine: string; esc_clears_call: boolean;
port: string; baud: number; wpm: number; weight: number;
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
};
const [wk, setWk] = useState<WKSettings>({
enabled: false, engine: 'winkeyer', esc_clears_call: true,
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
type QSLDefaults = {
qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string;
};
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '',
});
// External services (logbook upload). One block per service; only QRZ is
@@ -483,6 +521,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const locs: any = await ListTQSLStationLocations();
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
} catch { /* TQSL not installed — leave the dropdown empty */ }
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -636,6 +676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveWinkeyerSettings(wk as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
@@ -788,7 +829,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
async function profileActivate() {
if (!currentProfile) return;
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
try {
await ActivateProfile(currentProfile.id as number);
await reloadProfiles();
// Per-profile settings follow the active identity — reload the panels
// that are now scoped to the newly-active profile.
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
setActiveProfile(ap as Profile);
setQslDefaults(qd as any);
setExtSvc(es as any);
onSaved();
}
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRemove() {
@@ -1436,6 +1487,153 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
function WinkeyerPanel() {
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
const macros = [...s.macros];
while (macros.length <= i) macros.push({ label: '', text: '' });
macros[i] = { ...macros[i], ...patch };
return { ...s, macros };
});
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
return (
<>
<SectionHeader
title="CW Keyer (WinKeyer)"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
/>
<div className="space-y-4 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
Enable CW keyer (shows the keyer panel)
</label>
<div className="grid grid-cols-4 gap-3 items-end">
<div className="space-y-1">
<Label>Keyer engine</Label>
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
ESC clears the callsign too (otherwise ESC only stops transmission)
</label>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1 col-span-2">
<Label>Serial port</Label>
<div className="flex items-center gap-2">
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Weight</Label>
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Lead-in (ms)</Label>
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Tail (ms)</Label>
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Ratio (33-66)</Label>
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Farnsworth</Label>
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Sidetone (Hz)</Label>
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
</div>
<div className="space-y-1 col-span-2">
<Label>Paddle mode</Label>
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="iambic_b">Iambic B</SelectItem>
<SelectItem value="iambic_a">Iambic A</SelectItem>
<SelectItem value="ultimatic">Ultimatic</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
</label>
</div>
{/* Macro editor */}
<div className="border-t border-border/60 pt-3">
<Label className="text-sm font-medium">CW message macros (F1)</Label>
<p className="text-[11px] text-muted-foreground mb-2">
Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9N, 0T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
</p>
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
{Array.from({ length: 12 }).map((_, i) => {
const m = wk.macros[i] ?? { label: '', text: '' };
return (
<div key={i} className="flex items-center gap-2">
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
</div>
);
})}
</div>
</div>
</div>
</>
);
}
function statusForServer(id: number): ClusterServerStatus | undefined {
return clusterStatuses.find((s) => (s.server_id as number) === id);
}
@@ -1623,6 +1821,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
title="Confirmations"
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
/>
<ProfileScopeNote profile={activeProfileObj} />
<div className="space-y-3 max-w-2xl">
{/* Paper QSL */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -1690,7 +1889,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
{renderSelect('qrzcom_status', FULL_OPTIONS)}
</div>
<div />
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Confirmed</Label>
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div>
</div>
</div>
</div>
@@ -1923,6 +2125,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first)."
/>
<ProfileScopeNote profile={activeProfileObj} />
{/* Tab strip */}
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
{TABS.map((t) => (
@@ -2100,6 +2304,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
<Label className="text-sm">Force station callsign</Label>
<div>
<Input
value={lotw.force_station_callsign}
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
className="font-mono uppercase w-64"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
</div>
</div>
<Label className="text-sm">Key password</Label>
<Input
type="password"
@@ -2184,6 +2401,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function createNew() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await CreateDatabase(p);
await refreshDb();
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
@@ -2194,8 +2420,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<>
<SectionHeader
title="Database location"
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
title="Database"
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
/>
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
@@ -2210,15 +2436,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch to it</Button>
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database</Button>
<Button variant="outline" size="sm" onClick={openExisting}>Open existing</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
A database change takes effect on the next launch.
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
Any database change takes effect on the next launch.
</div>
{dbMsg && (
@@ -2228,6 +2456,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div>
)}
</div>
{/* Backup settings, merged into this Database section. */}
<div className="border-t border-border/60 mt-6 pt-5">
{BackupPanel()}
</div>
</>
);
}
@@ -2249,6 +2482,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
audio: () => <ComingSoon id="audio" icon={Server} />,
};