diff --git a/app.go b/app.go index ee280db..33a0cf1 100644 --- a/app.go +++ b/app.go @@ -8324,6 +8324,12 @@ func (a *App) SaveCATSettings(s CATSettings) error { 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) return nil } diff --git a/app_radios.go b/app_radios.go new file mode 100644 index 0000000..252e6d4 --- /dev/null +++ b/app_radios.go @@ -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 + } + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 693b40b..b826f3b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file +704fe1bf370b669665df0606fae8a69d \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b03aad..9f8c267 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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,94 @@ 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([]); + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+ + {open && has && ( +
+ {radios.map((r) => ( + + ))} +
+ +
+ )} +
+ ); +} + function shortCatError(err?: string): string { if (!err) return ''; const e = err.toLowerCase(); @@ -8312,11 +8401,16 @@ export default function App() {
setActiveTab('cluster')} /> - {catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}} - 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. */} + { setSettingsSection('cat'); setShowSettings(true); }} /> ([]); + const [activeRadio, setActiveRadioId] = useState(''); + const [radioBusy, setRadioBusy] = useState(false); const [catCfg, setCatCfg] = useState({ 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')} />
+ {/* 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. */} +
+
+ + + 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(() => {}); }} /> + + +
+

{t('cat.radioHint')}

+
+