feat: Support for Antenna Genius

This commit is contained in:
2026-06-21 20:15:30 +02:00
parent 8b7c42ec9b
commit b302d4d87b
14 changed files with 2315 additions and 6 deletions
+77 -2
View File
@@ -19,6 +19,7 @@ import {
RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -56,6 +57,8 @@ import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
@@ -332,6 +335,12 @@ export default function App() {
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
const [agEnabled, setAgEnabled] = useState(false);
// Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
@@ -966,6 +975,7 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -1061,6 +1071,38 @@ export default function App() {
return () => { alive = false; window.clearInterval(id); };
}, []);
// Poll the Antenna Genius switch for active antenna per port + the list.
// Re-read the enabled flag each tick so toggling it in Settings makes the
// top-bar icon appear/disappear without an app restart.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
try {
const s = (await GetAntGeniusStatus()) as AGStatus;
if (!alive || !s) return;
const now = Date.now();
const pend = agPending.current;
// Keep an optimistic selection until the device confirms it or it ages out.
if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
// Only update when something actually changed — avoids re-rendering the
// widget every 1.5s (which made buttons flicker on hover).
setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
} catch {}
};
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const agActivate = (port: number, antenna: number) => {
// Optimistic: reflect the change immediately and pin it for ~3s so the next
// poll (which may still carry the old cached value) can't revert it.
agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
};
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
@@ -2921,6 +2963,21 @@ export default function App() {
>
<Compass className="size-4" />
</button>
{agEnabled && (
<button
type="button"
onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Antenna className="size-4" />
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />}
</button>
)}
{chatAvailable && (
<button
type="button"
@@ -3219,7 +3276,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{chatShown && (
// relative + absolute inner: the chat takes the row height (set by the
@@ -3249,6 +3306,15 @@ export default function App() {
/>
</div>
)}
{showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<AntGeniusPanel
status={agStatus}
onActivate={agActivate}
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
/>
</div>
)}
{dvkEnabled && (
<div className="w-[264px] shrink-0 min-h-0">
<DvkPanel
@@ -3316,7 +3382,7 @@ export default function App() {
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && (
<div className="ml-2.5 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
<div className="ml-2.5 my-1.5 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */}
@@ -3383,6 +3449,7 @@ export default function App() {
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
@@ -3696,6 +3763,14 @@ export default function App() {
</TabsContent>
)}
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
is an Icom. */}
{catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel />
</TabsContent>
)}
{/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to
tune the rig. */}
+100
View File
@@ -0,0 +1,100 @@
import { Antenna, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export type AGAntenna = { index: number; name: string };
export type AGStatus = {
connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[];
};
// Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string {
const t = name.trim();
if (!t) return t;
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
// Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: {
status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void;
}) {
const list = status.antennas ?? [];
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{!status.connected ? (
<div className="text-center py-6 text-xs space-y-2">
<div className="text-muted-foreground italic animate-pulse">Connecting</div>
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
</div>
) : list.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
) : list.map((a) => {
const aActive = status.port_a === a.index;
const bActive = status.port_b === a.index;
const aTx = aActive && !!status.tx_a;
const bTx = bActive && !!status.tx_b;
const nameCls = (aTx || bTx)
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
: aActive
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
: bActive
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
</div>
);
})}
</div>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
type IcomState = {
available: boolean; model?: string; mode?: string;
af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number;
};
const ZERO: IcomState = {
available: false, af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1,
};
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
}) {
const v = Math.max(0, Math.min(100, value));
return (
<input
type="range" min={0} max={100} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
/>
);
}
function Segmented({ value, options, onChange }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} />
<Slider value={value} disabled={!on} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
{children}
</div>
);
}
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
// the last refresh plus optimistic updates. Front-panel knob changes show after
// the next ↻ Refresh.
export function IcomPanel() {
const [st, setSt] = useState<IcomState>(ZERO);
const [busy, setBusy] = useState(false);
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => {
setBusy(true);
try { await IcomRefresh(); } catch {}
await load();
setBusy(false);
};
useEffect(() => {
refresh();
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Optimistic local update + fire the command; the cache poll reconciles.
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
setSt((s) => ({ ...s, ...patch }));
fn().catch(() => {});
};
if (!st.available) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
Icom not connected. Enable the Icom CI-V backend in Settings CAT and connect the radio's USB port.
</div>
);
}
return (
<div className="h-full overflow-y-auto p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
</button>
</div>
<Card icon={Radio} title="Receive" accent="#2563eb">
<Row label="AF">
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
</Row>
<Row label="RF">
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
</Row>
<Row label="AGC">
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
</Row>
<Row label="Preamp">
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row>
<Row label="Att">
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row>
<Row label="Filter">
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
</Row>
</Card>
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
<LevelRow label="NB" on={st.nb} value={st.nb_level}
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} value={st.nr_level}
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
<div className="flex items-center gap-2">
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
<span className="text-xs text-muted-foreground">Auto notch filter</span>
</div>
</Card>
</div>
);
}
+78 -2
View File
@@ -12,6 +12,7 @@ import {
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -170,6 +171,7 @@ type SectionId =
| 'rotator'
| 'winkeyer'
| 'antenna'
| 'antgenius'
| 'audio';
type TreeNode =
@@ -207,6 +209,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'Rotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Audio devices', id: 'audio' },
],
},
@@ -232,6 +235,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
rotator: 'Rotator',
winkeyer: 'CW Keyer',
antenna: 'Antenna',
antgenius: 'Antenna Genius',
audio: 'Audio devices',
};
@@ -610,7 +614,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
@@ -626,6 +631,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' });
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
type WKSettings = {
@@ -883,6 +891,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setCatCfg(c);
setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
setBackupCfg(b as any);
setQslDefaults(qd as any);
setExtSvc(es as any);
@@ -922,6 +931,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {}
@@ -1089,6 +1099,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
@@ -1774,6 +1785,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1810,7 +1822,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
</>
)}
{catCfg.backend === 'omnirig' && (
{catCfg.backend === 'icom' && (
<>
<div className="space-y-1">
<Label>Icom CI-V port</Label>
<div className="flex gap-2">
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
<SelectTrigger><SelectValue placeholder="Select 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 type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud rate</Label>
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1 col-span-2">
<Label>CI-V address (hex)</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
<p className="text-xs text-muted-foreground">IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.</p>
</div>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
<>
<div className="space-y-1">
<Label>Poll interval (ms)</Label>
@@ -1977,6 +2022,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
function AntGeniusPanelSettings() {
return (
<>
<SectionHeader
title="Antenna Genius (4O3A)"
hint="OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B)."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
Enable Antenna Genius control
</label>
<div className="space-y-1">
<Label>Host / IP</Label>
<Input
value={antgenius.host ?? ''}
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.60"
className="font-mono"
/>
<p className="text-xs text-muted-foreground">TCP port is fixed at 9007.</p>
</div>
<p className="text-xs text-muted-foreground">
Once enabled, an Antenna Genius button appears in the top bar to show/hide the antenna-switch widget. In the widget, the A and B buttons select that antenna for the matching port; clicking an already-selected port deselects it (sets the port to None).
</p>
</div>
</>
);
}
function RotatorPanel() {
return (
<>
@@ -3732,6 +3807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings,
audio: AudioPanel,
};
+37
View File
@@ -5,6 +5,7 @@ import {qso} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {cluster} from '../models';
@@ -23,6 +24,10 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
@@ -195,6 +200,10 @@ export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
@@ -247,6 +256,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
@@ -291,6 +302,30 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>;
export function IcomSetAFGain(arg1:number):Promise<void>;
export function IcomSetAGC(arg1:string):Promise<void>;
export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>;
export function IcomSetNBLevel(arg1:number):Promise<void>;
export function IcomSetNR(arg1:boolean):Promise<void>;
export function IcomSetNRLevel(arg1:number):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
@@ -421,6 +456,8 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
+72
View File
@@ -18,6 +18,14 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
}
export function AntGeniusDeselect(arg1) {
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
}
export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
@@ -362,6 +370,14 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings']();
}
export function GetAntGeniusStatus() {
return window['go']['main']['App']['GetAntGeniusStatus']();
}
export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
@@ -466,6 +482,10 @@ export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
export function GetIcomState() {
return window['go']['main']['App']['GetIcomState']();
}
export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
@@ -554,6 +574,54 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh']();
}
export function IcomSetAFGain(arg1) {
return window['go']['main']['App']['IcomSetAFGain'](arg1);
}
export function IcomSetAGC(arg1) {
return window['go']['main']['App']['IcomSetAGC'](arg1);
}
export function IcomSetANF(arg1) {
return window['go']['main']['App']['IcomSetANF'](arg1);
}
export function IcomSetAtt(arg1) {
return window['go']['main']['App']['IcomSetAtt'](arg1);
}
export function IcomSetFilter(arg1) {
return window['go']['main']['App']['IcomSetFilter'](arg1);
}
export function IcomSetNB(arg1) {
return window['go']['main']['App']['IcomSetNB'](arg1);
}
export function IcomSetNBLevel(arg1) {
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
}
export function IcomSetNR(arg1) {
return window['go']['main']['App']['IcomSetNR'](arg1);
}
export function IcomSetNRLevel(arg1) {
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
}
export function IcomSetPreamp(arg1) {
return window['go']['main']['App']['IcomSetPreamp'](arg1);
}
export function IcomSetRFGain(arg1) {
return window['go']['main']['App']['IcomSetRFGain'](arg1);
}
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
}
@@ -814,6 +882,10 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
}
export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
+121
View File
@@ -65,6 +65,69 @@ export namespace adif {
}
export namespace antgenius {
export class Antenna {
index: number;
name: string;
static createFrom(source: any = {}) {
return new Antenna(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.index = source["index"];
this.name = source["name"];
}
}
export class Status {
connected: boolean;
host?: string;
last_error?: string;
port_a: number;
port_b: number;
tx_a: boolean;
tx_b: boolean;
antennas: Antenna[];
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.host = source["host"];
this.last_error = source["last_error"];
this.port_a = source["port_a"];
this.port_b = source["port_b"];
this.tx_a = source["tx_a"];
this.tx_b = source["tx_b"];
this.antennas = this.convertValues(source["antennas"], Antenna);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace audio {
export class Device {
@@ -541,6 +604,44 @@ export namespace cat {
return a;
}
}
export class IcomTXState {
available: boolean;
model?: string;
mode?: string;
af_gain: number;
rf_gain: number;
nb: boolean;
nb_level: number;
nr: boolean;
nr_level: number;
anf: boolean;
agc?: string;
preamp: number;
att: number;
filter: number;
static createFrom(source: any = {}) {
return new IcomTXState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.mode = source["mode"];
this.af_gain = source["af_gain"];
this.rf_gain = source["rf_gain"];
this.nb = source["nb"];
this.nb_level = source["nb_level"];
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.anf = source["anf"];
this.agc = source["agc"];
this.preamp = source["preamp"];
this.att = source["att"];
this.filter = source["filter"];
}
}
export class RigState {
enabled: boolean;
connected: boolean;
@@ -857,6 +958,20 @@ export namespace lookup {
export namespace main {
export class AntGeniusSettings {
enabled: boolean;
host: string;
static createFrom(source: any = {}) {
return new AntGeniusSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
}
}
export class AudioSettings {
from_radio: string;
to_radio: string;
@@ -1042,6 +1157,9 @@ export namespace main {
flex_host: string;
flex_port: number;
flex_spots: boolean;
icom_port: string;
icom_baud: number;
icom_addr: number;
poll_ms: number;
delay_ms: number;
digital_default: string;
@@ -1058,6 +1176,9 @@ export namespace main {
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];