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 } } }