feat(cat): several radios, switched from the status bar

The answer to 'I have two rigs' was 'make two profiles', which is a heavy
instrument for a light question: a profile carries the whole station —
logbook, callsign, awards, cluster, macros — so switching one to change
which radio is connected takes all of that with it, including a change of
logbook when the profiles point at different databases.

Radios are a list now, the way amplifiers already are. The CAT chip in the
status bar opens it, one click connects the chosen rig, and nothing else
about the station moves.

The storage keeps the ACTIVE radio in the same settings keys everything
already reads (cat.backend, cat.icom_port, …), and switching writes the
chosen entry into them and reloads the link. So the consoles, the CAT
sharing, the band-follow and every panel see exactly what they saw before
and needed no change at all — the list is a second store beside the
configuration, not a replacement for it.

Details that matter more than they look:
  - An operator who has run one rig for a year opens the list and finds it
    there, because a missing list reads as the CURRENT settings rather
    than as nothing. It is written on the first save, not on the first
    read.
  - Editing the CAT panel updates the active entry, or an edit made before
    switching away would be lost on the way back.
  - A new radio starts as a COPY of the current one: a second rig is
    usually the same shape with one port changed, and an empty form is a
    form to fill in twice.
  - Switching saves the panel first, so edits to the radio being left
    behind are kept.
  - The chip only becomes a menu with more than one radio; with one it
    opens the CAT settings exactly as it always did.
This commit is contained in:
2026-08-27 00:22:19 +02:00
parent 23f425f95f
commit 4689505fb9
8 changed files with 455 additions and 9 deletions
+85 -6
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -56,6 +56,7 @@ import {
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -290,6 +291,79 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
// RadioChip — the CAT status chip, and the radio picker behind it.
function RadioChip({ catUp, catState, onOpenSettings }: {
catUp: boolean; catState: any; onOpenSettings: () => void;
}) {
const [radios, setRadios] = useState<any[]>([]);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Re-read on every open rather than on a timer: the list changes when the
// operator edits it in Settings, which is exactly when they are not looking
// at this chip.
const load = () => { ListRadios().then((r: any) => setRadios(r ?? [])).catch(() => {}); };
useEffect(() => { load(); }, []);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const label = catUp
? (catState.rig || 'CAT')
: (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT');
const many = radios.length > 1;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => { if (!many) { onOpenSettings(); return; } load(); setOpen((o) => !o); }}
title={many
? 'Switch radio — right-click for the CAT settings'
: (catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT'))}
onContextMenu={(e) => { e.preventDefault(); onOpenSettings(); }}
className={cn('inline-flex items-center gap-1.5 h-5 px-2 rounded-full border text-[11px] transition-colors',
'border-border hover:bg-muted cursor-pointer')}
>
<span className={cn('size-2 rounded-full', catUp ? 'bg-success' : 'bg-muted-foreground/40')} />
<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{label}</span>
{many && <ChevronUp className="size-3 opacity-60" />}
</button>
{open && many && (
<div className="absolute bottom-full left-0 mb-1 z-50 min-w-44 rounded-md border border-border bg-card shadow-lg py-1">
{radios.map((r) => (
<button
key={r.id}
type="button"
onClick={() => {
setOpen(false);
if (r.active) return;
SetActiveRadio(r.id).then(load).catch(() => {});
}}
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
r.active && 'font-semibold text-primary')}
>
<span className={cn('size-1.5 rounded-full shrink-0', r.active ? 'bg-success' : 'bg-muted-foreground/30')} />
<span className="flex-1 truncate">{r.name}</span>
<span className="text-[10px] text-muted-foreground uppercase">{r.backend}</span>
</button>
))}
<div className="my-1 border-t border-border/60" />
<button type="button" onClick={() => { setOpen(false); onOpenSettings(); }}
className="w-full px-2.5 py-1 text-left text-xs text-muted-foreground hover:bg-muted">
Radios
</button>
</div>
)}
</div>
);
}
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
@@ -8312,11 +8386,16 @@ export default function App() {
</span>
<div className="w-px h-4 bg-border mx-1" />
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
<Chip
on={catUp}
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
{/* The CAT chip is also the radio switch.
With one radio it behaves as it always did click opens the CAT
settings. With several it opens a menu: pick one and OpsLog
reconnects to it, leaving the rest of the station alone. The
answer to "I have two rigs" used to be "make two profiles",
which moves the logbook with it. */}
<RadioChip
catUp={catUp}
catState={catState}
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
/>
<Chip
on={rotatorHeading.enabled && rotatorHeading.ok}
+81 -1
View File
@@ -9,7 +9,7 @@ import {
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
@@ -1596,6 +1596,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// exotic or experimental bands not listed).
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
// The saved radios. The CAT panel edits ONE of them — whichever is on the air
// — and the list is what makes switching possible without touching profiles.
const [radios, setRadios] = useState<any[]>([]);
const [activeRadio, setActiveRadioId] = useState('');
const [radioBusy, setRadioBusy] = useState(false);
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
@@ -2192,6 +2197,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// on a normal open and Save then wrote an empty list over the operator's
// buttons. See rotorPresetsLoaded for the belt to this brace.
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try {
const rl: any = await GetRadios();
setRadios(rl ?? []);
const act = (rl ?? []).find((r: any) => r.active) ?? (rl ?? [])[0];
setActiveRadioId(act?.id ?? '');
} catch { /* one radio, never listed — the panel works as it always did */ }
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -3189,6 +3200,75 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
hint={t('cat.hint')}
/>
<div className="space-y-4 max-w-3xl">
{/* RADIOS. Everything below edits the one selected here, and the status
bar's CAT chip switches between them without moving the rest of the
station which is what making a profile per radio used to do. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<Label className="shrink-0">{t('cat.radio')}</Label>
<Select value={activeRadio || undefined} onValueChange={(v) => {
if (v === activeRadio || radioBusy) return;
// Saving first: the panel may hold edits to the radio being left
// behind, and switching without them would throw them away.
setRadioBusy(true);
SaveCATSettings(catCfg as any)
.then(() => SetActiveRadio(v))
.then(async () => {
setActiveRadioId(v);
setCatCfg(await GetCATSettings() as any);
setRadios(((await GetRadios()) ?? []) as any[]);
})
.catch((e: any) => setErr(String(e?.message ?? e)))
.finally(() => setRadioBusy(false));
}}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('cat.radio')} /></SelectTrigger>
<SelectContent>
{radios.map((r: any, i: number) => (
<SelectItem key={r.id} value={r.id}>{r.name?.trim() || `Radio ${i + 1}`}</SelectItem>
))}
</SelectContent>
</Select>
<Input className="h-9 w-44" placeholder={t('cat.radioNamePh')}
value={(radios.find((r: any) => r.id === activeRadio)?.name) ?? ''}
onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, name: e.target.value } : r))}
onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
<Button type="button" variant="outline" size="sm" className="h-9 shrink-0" disabled={radioBusy}
title={t('cat.radioAddHint')}
onClick={() => {
// A new radio starts from the CURRENT one rather than from
// nothing: a second rig is usually the same shape as the first
// with one port changed, and an empty form is a form to fill in
// twice.
const next = [...radios, { id: '', name: '', backend: catCfg.backend, settings: catCfg }];
setRadioBusy(true);
SaveRadios(next as any)
.then(() => GetRadios())
.then((rl: any) => setRadios(rl ?? []))
.catch((e: any) => setErr(String(e?.message ?? e)))
.finally(() => setRadioBusy(false));
}}>+</Button>
<Button type="button" variant="ghost" size="sm" className="h-9 shrink-0 text-danger"
disabled={radioBusy || radios.length < 2}
title={t('cat.radioRemoveHint')}
onClick={() => {
const next = radios.filter((r: any) => r.id !== activeRadio);
setRadioBusy(true);
SaveRadios(next as any)
.then(() => SetActiveRadio(next[0].id))
.then(async () => {
setActiveRadioId(next[0].id);
setCatCfg(await GetCATSettings() as any);
setRadios(((await GetRadios()) ?? []) as any[]);
})
.catch((e: any) => setErr(String(e?.message ?? e)))
.finally(() => setRadioBusy(false));
}}>
<Trash2 className="size-3.5" />
</Button>
</div>
<p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
{t('cat.enable')}
File diff suppressed because one or more lines are too long
+10
View File
@@ -43,6 +43,8 @@ export function ADIFVersion():Promise<string>;
export function ActivateProfile(arg1:number):Promise<void>;
export function ActiveRadioID():Promise<string>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
@@ -541,6 +543,8 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetQSORate():Promise<main.QSORate>;
export function GetRadios():Promise<Array<main.RadioConfig>>;
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
@@ -745,6 +749,8 @@ export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
export function ListRadios():Promise<Array<main.RadioListEntry>>;
export function ListSerialPorts():Promise<Array<string>>;
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
@@ -1037,6 +1043,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRadios(arg1:Array<main.RadioConfig>):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
@@ -1083,6 +1091,8 @@ export function SendLogToDeveloper():Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetActiveRadio(arg1:string):Promise<void>;
export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
+20
View File
@@ -26,6 +26,10 @@ export function ActivateProfile(arg1) {
return window['go']['main']['App']['ActivateProfile'](arg1);
}
export function ActiveRadioID() {
return window['go']['main']['App']['ActiveRadioID']();
}
export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
@@ -1022,6 +1026,10 @@ export function GetQSORate() {
return window['go']['main']['App']['GetQSORate']();
}
export function GetRadios() {
return window['go']['main']['App']['GetRadios']();
}
export function GetRelayAuto() {
return window['go']['main']['App']['GetRelayAuto']();
}
@@ -1430,6 +1438,10 @@ export function ListQSOFiltered(arg1) {
return window['go']['main']['App']['ListQSOFiltered'](arg1);
}
export function ListRadios() {
return window['go']['main']['App']['ListRadios']();
}
export function ListSerialPorts() {
return window['go']['main']['App']['ListSerialPorts']();
}
@@ -2014,6 +2026,10 @@ export function SaveQSLDefaults(arg1) {
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
}
export function SaveRadios(arg1) {
return window['go']['main']['App']['SaveRadios'](arg1);
}
export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
@@ -2106,6 +2122,10 @@ export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetActiveRadio(arg1) {
return window['go']['main']['App']['SetActiveRadio'](arg1);
}
export function SetActiveRotor(arg1) {
return window['go']['main']['App']['SetActiveRotor'](arg1);
}
+52
View File
@@ -3569,6 +3569,58 @@ export namespace main {
}
}
export class RadioConfig {
id: string;
name: string;
settings: CATSettings;
static createFrom(source: any = {}) {
return new RadioConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.settings = this.convertValues(source["settings"], CATSettings);
}
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 class RadioListEntry {
id: string;
name: string;
backend: string;
active: boolean;
static createFrom(source: any = {}) {
return new RadioListEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.backend = source["backend"];
this.active = source["active"];
}
}
export class RelayAutoRule {
device_id: string;
relay: number;