feat(cat): pick the radio, then how it is connected

The backend list mixed two different questions. 'Icom (USB)' and 'Icom
(network)' were separate entries; Kenwood and Elecraft hid the same
choice in a field further down; and Flex, TCI and OmniRig sat in the same
list as if they were the same kind of answer. An operator picks a radio
and then says how it is plugged in, so that is what the panel asks, in
that order, and the two answers together choose the backend.

Each brand offers only the connections it has, and a brand with one way
in still shows it, greyed: 'there is no choice here' is an answer and an
empty space is not. The stored backend names are unchanged — 'icom-net'
is still 'icom-net' — so a settings file written by an older build keeps
working, and brand+connection are DERIVED from it rather than held
alongside it, which is what keeps them from drifting apart when something
else writes the backend.
This commit is contained in:
2026-08-25 17:49:07 +02:00
parent bd540ef18b
commit 712d83a012
3 changed files with 90 additions and 31 deletions
+86 -27
View File
@@ -1476,6 +1476,43 @@ const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-9700', addr: 0xA2 },
];
// The radios OpsLog talks to, by BRAND, and the ways each one can be reached.
//
// Three connections exist in the world and each brand has its own subset:
// usb — a COM port, the radio's own USB or a serial cable
// bridge — RS-232 carried over Ethernet by a serial bridge (ser2net, an
// Ethernet-serial box): the SAME CAT bytes, a socket instead of wire
// native — the manufacturer's own network protocol, a session of its own
//
// The backend NAME stored in settings is unchanged — 'icom-net' is still
// 'icom-net' — because a settings file written by an older build has to keep
// working. This table is only how the two questions map onto it.
const CAT_BRANDS: { id: string; label: string; links: string[]; backend: (link: string) => string }[] = [
{ id: 'elecraft', label: 'Elecraft K3 / K4', links: ['usb', 'bridge', 'native'], backend: () => 'elecraft' },
{ id: 'kenwood', label: 'Kenwood', links: ['usb', 'bridge', 'native'], backend: () => 'kenwood' },
{ id: 'yaesu', label: 'Yaesu', links: ['usb'], backend: () => 'yaesu' },
{ id: 'icom', label: 'Icom', links: ['usb', 'native'], backend: (l) => (l === 'native' ? 'icom-net' : 'icom') },
{ id: 'flex', label: 'FlexRadio (SmartSDR)', links: ['native'], backend: () => 'flex' },
{ id: 'tci', label: 'Expert Electronics / SunSDR (TCI)', links: ['native'], backend: () => 'tci' },
{ id: 'xiegu', label: 'Xiegu', links: ['usb'], backend: () => 'xiegu' },
{ id: 'omnirig', label: 'OmniRig (any rig)', links: ['usb'], backend: () => 'omnirig' },
];
// brandOfBackend reads the stored backend back into the two questions.
function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string; link: string } {
switch (backend) {
case 'icom-net': return { brand: 'icom', link: 'native' };
case 'icom': return { brand: 'icom', link: 'usb' };
case 'flex': return { brand: 'flex', link: 'native' };
case 'tci': return { brand: 'tci', link: 'native' };
case 'yaesu': return { brand: 'yaesu', link: 'usb' };
case 'xiegu': return { brand: 'xiegu', link: 'usb' };
case 'kenwood': case 'elecraft':
return { brand: backend, link: kenwoodLink || 'usb' };
default: return { brand: 'omnirig', link: 'usb' };
}
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
@@ -1520,6 +1557,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// Brand + connection, derived from the stored backend rather than held
// separately: two sources for one fact drift apart the first time something
// else writes the backend (loading a profile, an older settings file).
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
const applyCatBrand = (id: string) => {
const b = CAT_BRANDS.find((x) => x.id === id);
if (!b) return;
// Keep the connection when the new brand offers it, otherwise take its
// first — picking Flex from Kenwood-over-USB has to land on something.
const link = b.links.includes(catLink) ? catLink : b.links[0];
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
};
const applyCatLink = (link: string) => {
const b = CAT_BRANDS.find((x) => x.id === catBrand);
if (!b || !b.links.includes(link)) return;
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
};
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
@@ -3019,24 +3075,42 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('cat.enable')}
</label>
<div className="grid grid-cols-2 gap-3">
{/* BRAND, then CONNECTION.
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
were separate entries, while Kenwood and Elecraft hid the same
choice in a field further down. An operator picks a radio, then
says how it is plugged in so that is what the panel asks, in
that order, and the two answers together choose the backend. */}
<div className="grid grid-cols-2 gap-3 items-start">
<div className="space-y-1">
<Label>{t('cat.backend')}</Label>
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<Label>{t('cat.brand')}</Label>
<Select value={catBrand} onValueChange={(v) => applyCatBrand(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
<SelectItem value="elecraft">{t('cat.optElecraft')}</SelectItem>
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
{CAT_BRANDS.map((b) => <SelectItem key={b.id} value={b.id}>{b.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('cat.kwLink')}</Label>
{/* One button per connection the brand actually has. A brand with
a single way in still shows it, greyed: "there is no choice
here" is an answer, and an empty space is not. */}
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs w-full">
{(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? ['usb']).map((l) => (
<button key={l} type="button"
disabled={(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? []).length < 2}
onClick={() => applyCatLink(l)}
className={cn('flex-1 px-2 py-1.5 font-medium border-l border-border first:border-l-0 disabled:opacity-70',
catLink === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{l === 'usb' ? t('cat.kwLinkUsb') : l === 'bridge' ? t('cat.kwLinkBridge') : t('cat.kwLinkNative')}
</button>
))}
</div>
{catLink === 'native' && catBrand !== 'flex' && catBrand !== 'tci' && catBrand !== 'icom' && (
<p className="text-xs text-warning">{t('cat.kwLinkNativeHint')}</p>
)}
</div>
{catCfg.backend === 'omnirig' && (
<div className="space-y-1">
<Label>{t('cat.omnirigRig')}</Label>
@@ -3185,21 +3259,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
The two were offered side by side with nothing to say which one
the backend would use it prefers the network address whenever
the field is not empty, which is invisible from here. */}
<div className="space-y-1">
<Label>{t('cat.kwLink')}</Label>
<Select value={(catCfg as any).kenwood_link || 'usb'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_link: v } as any))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="usb">{t('cat.kwLinkUsb')}</SelectItem>
<SelectItem value="bridge">{t('cat.kwLinkBridge')}</SelectItem>
<SelectItem value="native">{t('cat.kwLinkNative')}</SelectItem>
</SelectContent>
</Select>
{((catCfg as any).kenwood_link || 'usb') === 'native' && (
<p className="text-xs text-warning">{t('cat.kwLinkNativeHint')}</p>
)}
</div>
{(((catCfg as any).kenwood_link || 'usb') === 'usb') && (<>
<div className="space-y-1">
<Label>{t('cat.kenwoodPort')}</Label>
File diff suppressed because one or more lines are too long