merge: several radios, switched from the status bar
The answer to 'I have two rigs' was 'make two profiles', which moves the whole station — logbook included — to change which radio is connected. Radios are a list now, like amplifiers, and the CAT chip switches between them while everything else stays put. The active radio still lives in the settings keys the rest of OpsLog reads, so the consoles, the CAT sharing and the band-follow needed no change at all.
This commit is contained in:
@@ -8324,6 +8324,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The saved radio follows what was just edited.
|
||||||
|
//
|
||||||
|
// Without this, an operator with two rigs edits the one on the air, switches
|
||||||
|
// to the other and back, and finds the edit gone — the list would still hold
|
||||||
|
// what that entry looked like when it was created. See app_radios.go.
|
||||||
|
a.syncActiveRadio(s)
|
||||||
a.restartAsync("cat", a.reloadCAT)
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Several radios, and the one that is on the air.
|
||||||
|
//
|
||||||
|
// Until now 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 — and switching one to change
|
||||||
|
// which radio is connected takes all of that with it, including a logbook
|
||||||
|
// change if the profiles point at different databases.
|
||||||
|
//
|
||||||
|
// So radios are a LIST, the way amplifiers already are, and switching is one
|
||||||
|
// click on the CAT chip in the status bar. Nothing else about the station
|
||||||
|
// moves.
|
||||||
|
//
|
||||||
|
// The storage deliberately keeps the ACTIVE radio in the settings keys the rest
|
||||||
|
// of OpsLog already reads (cat.backend, cat.icom_port, …). Switching writes the
|
||||||
|
// chosen entry into those keys and reloads the link, so every consumer — the
|
||||||
|
// consoles, the CAT sharing, the band-follow, the panels — sees exactly what it
|
||||||
|
// saw before and needed no change at all. The list is a second store beside it,
|
||||||
|
// not a replacement for it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// keyRadiosList holds the saved radios as JSON.
|
||||||
|
keyRadiosList = "cat.radios.json"
|
||||||
|
// keyRadioActive is the id of the one currently connected.
|
||||||
|
keyRadioActive = "cat.radios.active"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RadioConfig is one saved radio: a name and the CAT settings that reach it.
|
||||||
|
type RadioConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Settings is the whole CAT configuration for this radio — the same shape
|
||||||
|
// the CAT panel has always edited, so a saved radio is exactly "what the
|
||||||
|
// settings said the day it was saved".
|
||||||
|
Settings CATSettings `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// radioLabel is what the status bar shows when the operator never named one.
|
||||||
|
func radioLabel(c RadioConfig, i int) string {
|
||||||
|
if n := strings.TrimSpace(c.Name); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if b := strings.TrimSpace(c.Settings.Backend); b != "" {
|
||||||
|
return strings.ToUpper(b)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Radio %d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRadios returns the saved radios.
|
||||||
|
//
|
||||||
|
// When nothing was ever saved, the CURRENT settings are presented as the single
|
||||||
|
// entry — so an operator who has been running one rig for a year opens the list
|
||||||
|
// and finds it there, rather than an empty box suggesting their configuration
|
||||||
|
// has been lost. It is persisted on the next save, not here: reading a list
|
||||||
|
// should not write one.
|
||||||
|
func (a *App) GetRadios() ([]RadioConfig, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
raw := a.settingOr(keyRadiosList, "")
|
||||||
|
if strings.TrimSpace(raw) != "" {
|
||||||
|
var list []RadioConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &list); err == nil && len(list) > 0 {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur, err := a.GetCATSettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []RadioConfig{{ID: "radio-1", Name: "", Settings: cur}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRadios stores the list, giving an id to anything new.
|
||||||
|
func (a *App) SaveRadios(list []RadioConfig) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if strings.TrimSpace(list[i].ID) == "" {
|
||||||
|
list[i].ID = fmt.Sprintf("radio-%d-%d", time.Now().Unix(), i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyRadiosList, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveRadioID is the id of the radio currently on the air, or the first one
|
||||||
|
// when nothing was ever chosen.
|
||||||
|
func (a *App) ActiveRadioID() string {
|
||||||
|
id := strings.TrimSpace(a.settingOr(keyRadioActive, ""))
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == id {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The stored id names a radio that has since been deleted. The first one is
|
||||||
|
// a better answer than an empty selection: the CAT link is up, and it is up
|
||||||
|
// on SOMETHING.
|
||||||
|
return list[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadioListEntry is what the status-bar menu needs: enough to draw a row.
|
||||||
|
//
|
||||||
|
// Name is what the OPERATOR typed, empty when they typed nothing; Label is what
|
||||||
|
// to draw when there is no better idea. The two are separate because the caller
|
||||||
|
// has a better idea than we do: the status bar knows what the radio calls
|
||||||
|
// itself over CAT, and "FTDX10" beats "Radio 2" — but only where the operator
|
||||||
|
// has not given it a name of their own, which beats both.
|
||||||
|
type RadioListEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRadios is the status bar's view of the list.
|
||||||
|
func (a *App) ListRadios() []RadioListEntry {
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
active := a.ActiveRadioID()
|
||||||
|
out := make([]RadioListEntry, 0, len(list))
|
||||||
|
for i, r := range list {
|
||||||
|
out = append(out, RadioListEntry{
|
||||||
|
ID: r.ID, Name: strings.TrimSpace(r.Name), Label: radioLabel(r, i),
|
||||||
|
Backend: r.Settings.Backend, Active: r.ID == active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveRadio connects the given radio and leaves the rest of the station
|
||||||
|
// alone.
|
||||||
|
//
|
||||||
|
// The chosen entry's settings become THE CAT settings — SaveCATSettings writes
|
||||||
|
// them and restarts the link — so switching rigs is the same operation as
|
||||||
|
// editing the CAT panel and pressing Save, which is a path that already works
|
||||||
|
// everywhere it needs to.
|
||||||
|
func (a *App) SetActiveRadio(id string) error {
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
for i, r := range list {
|
||||||
|
if r.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Persisted BEFORE the link is rebuilt: reloadCAT can take a moment on a
|
||||||
|
// radio that is switched off, and an operator who closes OpsLog during
|
||||||
|
// that moment should still come back to the rig they chose.
|
||||||
|
a.setSetting(keyRadioActive, id)
|
||||||
|
// The list is written back as well when it was only ever implicit, so
|
||||||
|
// the first switch is also what makes the list real.
|
||||||
|
if strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
_ = a.SaveRadios(list)
|
||||||
|
}
|
||||||
|
applog.Printf("cat: switching to %q (%s)", radioLabel(r, i), r.Settings.Backend)
|
||||||
|
return a.SaveCATSettings(r.Settings)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no radio with id %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncActiveRadio writes the settings just saved into the active entry of the
|
||||||
|
// list, so the list and the live configuration never disagree.
|
||||||
|
//
|
||||||
|
// Only when a list actually exists: an operator with one radio who has never
|
||||||
|
// opened the list has nothing to keep in step, and writing one here would
|
||||||
|
// create a list as a side effect of saving the CAT panel.
|
||||||
|
func (a *App) syncActiveRadio(s CATSettings) {
|
||||||
|
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := a.ActiveRadioID()
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ID == id {
|
||||||
|
list[i].Settings = s
|
||||||
|
_ = a.SaveRadios(list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
f9b41e192918fa2511f68cd1b361fcd3
|
704fe1bf370b669665df0606fae8a69d
|
||||||
+100
-6
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
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 { 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 { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -290,6 +291,94 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
|||||||
// shortCatError condenses a backend error into a few words for the topbar
|
// shortCatError condenses a backend error into a few words for the topbar
|
||||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
// (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]);
|
||||||
|
|
||||||
|
// What the operator called this radio comes first.
|
||||||
|
//
|
||||||
|
// The chip showed what the RADIO calls itself over CAT — "Kenwood (911)" for
|
||||||
|
// a K3, a bare "CAT" for a Flex that reports nothing useful — which is the
|
||||||
|
// answer to a question nobody asked once the rig has a name in the list. The
|
||||||
|
// model identity is still there, in the tooltip.
|
||||||
|
const active = radios.find((r: any) => r.active);
|
||||||
|
const named = (active?.name || '').trim();
|
||||||
|
const label = catUp
|
||||||
|
? (named || catState.rig || 'CAT')
|
||||||
|
: (catState.enabled ? (named || shortCatError(catState.error) || 'CAT off') : (named || 'CAT'));
|
||||||
|
// The menu opens with ONE radio too.
|
||||||
|
//
|
||||||
|
// It was only shown from two, on the reasoning that a single radio has
|
||||||
|
// nothing to switch to — but that hides the feature exactly from the operator
|
||||||
|
// who has not made a second radio yet, and the click lands on the settings
|
||||||
|
// dialog they were not asking for. With one radio the menu shows that radio
|
||||||
|
// and the way to add another.
|
||||||
|
const has = radios.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (!has) { onOpenSettings(); return; } load(); setOpen((o) => !o); }}
|
||||||
|
title={catUp
|
||||||
|
? `${catState.rig || catState.backend || 'connected'}${has ? ' — click to switch radio, right-click for the CAT settings' : ''}`
|
||||||
|
: (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>
|
||||||
|
{has && <ChevronUp className="size-3 opacity-60" />}
|
||||||
|
</button>
|
||||||
|
{open && has && (
|
||||||
|
<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 || '').trim() || r.label}</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.length > 1 ? 'Radios…' : 'Add a radio…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shortCatError(err?: string): string {
|
function shortCatError(err?: string): string {
|
||||||
if (!err) return '';
|
if (!err) return '';
|
||||||
const e = err.toLowerCase();
|
const e = err.toLowerCase();
|
||||||
@@ -8312,11 +8401,16 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<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={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||||
<Chip
|
{/* The CAT chip is also the radio switch.
|
||||||
on={catUp}
|
With one radio it behaves as it always did — click opens the CAT
|
||||||
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>}
|
settings. With several it opens a menu: pick one and OpsLog
|
||||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
reconnects to it, leaving the rest of the station alone. The
|
||||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
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
|
<Chip
|
||||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
@@ -1596,6 +1596,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// exotic or experimental bands not listed).
|
// exotic or experimental bands not listed).
|
||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = 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>({
|
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,
|
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: '',
|
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
|
// on a normal open and Save then wrote an empty list over the operator's
|
||||||
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
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 { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -3189,6 +3200,75 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
hint={t('cat.hint')}
|
hint={t('cat.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-3xl">
|
<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() || r.label || `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">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
{t('cat.enable')}
|
{t('cat.enable')}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+10
@@ -43,6 +43,8 @@ export function ADIFVersion():Promise<string>;
|
|||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ActiveRadioID():Promise<string>;
|
||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
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 GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRadios():Promise<Array<main.RadioConfig>>;
|
||||||
|
|
||||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
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 ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||||
|
|
||||||
export function ListSerialPorts():Promise<Array<string>>;
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
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 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 SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):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 SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function ActivateProfile(arg1) {
|
|||||||
return window['go']['main']['App']['ActivateProfile'](arg1);
|
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ActiveRadioID() {
|
||||||
|
return window['go']['main']['App']['ActiveRadioID']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AddQSO(arg1) {
|
export function AddQSO(arg1) {
|
||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1026,10 @@ export function GetQSORate() {
|
|||||||
return window['go']['main']['App']['GetQSORate']();
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRadios() {
|
||||||
|
return window['go']['main']['App']['GetRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRelayAuto() {
|
export function GetRelayAuto() {
|
||||||
return window['go']['main']['App']['GetRelayAuto']();
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1438,10 @@ export function ListQSOFiltered(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListRadios() {
|
||||||
|
return window['go']['main']['App']['ListRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSerialPorts() {
|
export function ListSerialPorts() {
|
||||||
return window['go']['main']['App']['ListSerialPorts']();
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
}
|
}
|
||||||
@@ -2014,6 +2026,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRadios(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRadios'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRelayAuto(arg1) {
|
export function SaveRelayAuto(arg1) {
|
||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2106,6 +2122,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRadio'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetActiveRotor(arg1) {
|
export function SetActiveRotor(arg1) {
|
||||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3569,6 +3569,60 @@ 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;
|
||||||
|
label: 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.label = source["label"];
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.active = source["active"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RelayAutoRule {
|
export class RelayAutoRule {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
relay: number;
|
relay: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user