Files
OpsLog/app_radios.go
T
rouggy f98a831195 feat(cat): MY_RIG follows the radio that is connected
With one rig, MY_RIG belonged in Operating conditions and nowhere else.
With several, that becomes the wrong place: operating conditions describe
a PLAN — 'on 20 m I use the beam and the 7300' — while the question a
logged QSO asks is which radio was keying.

So each saved radio carries its own MY_RIG, consulted BEFORE the per-band
station. When the two disagree — a second rig borrowed for one evening on
20 m — the one that is transmitting is the true answer.

Left empty it changes nothing at all: the old chain (band default, then
the profile's rig) answers exactly as it did, which is what every
single-radio station will keep doing without touching a setting.
2026-08-27 01:03:27 +02:00

238 lines
7.9 KiB
Go

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"`
// MyRig is what goes into MY_RIG on a QSO made with this radio.
//
// It belongs here rather than in Operating conditions once there is more
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
// the beam and the 7300" — while this is the fact of which radio is keying.
// Left empty, nothing changes: the old chain (band default, then the
// profile's rig) answers exactly as it did.
MyRig string `json:"my_rig"`
}
// 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
}
}
}
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
//
// Consulted BEFORE the per-band default, and deliberately: the band default is
// what the operator planned to use on that band, this is which radio is
// actually on the air. When they disagree — a second rig borrowed for one
// evening on 20 m — the one that is transmitting is the true answer.
func (a *App) activeRadioMyRig() string {
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
return "" // no list was ever made: nothing to say, nothing changes
}
list, err := a.GetRadios()
if err != nil {
return ""
}
id := a.ActiveRadioID()
for _, r := range list {
if r.ID == id {
return strings.TrimSpace(r.MyRig)
}
}
return ""
}