package main // Panadapter auto-zoom on a spot click. // // A CW signal is 100 Hz wide, an FT8 slot 50 Hz in a 3 kHz sub-band, an SSB // station 2.7 kHz. One panadapter width cannot serve all three: at 200 kHz the // CW station you clicked is a hair, and at 25 kHz an FT8 window shows one // eighth of the traffic. So the width follows the MODE of the spot, which is // the only thing that determines how much spectrum is worth looking at. // // The command is SmartSDR's own: "display pan s bandwidth=", with an // optional re-centre. Same approach as DXHunter, which is where the defaults // below come from. import ( "encoding/json" "strings" "hamlog/internal/cat" ) const keyFlexZoom = "flex.spot_zoom" // FlexZoomSettings is the per-mode-group visible width, in kHz. // // kHz and not MHz: an operator thinks "25 kHz of CW", and a form asking for // 0.025 invites a zero too many — which on a panadapter is not an error message // but a display that has silently gone somewhere else. type FlexZoomSettings struct { Enabled bool `json:"enabled"` CWkHz int `json:"cw_khz"` SSBkHz int `json:"ssb_khz"` DigikHz int `json:"digi_khz"` // Centre re-centres the panadapter on EVERY spot, not just the ones that // would otherwise fall outside the new window — the display always follows. // Off by default: an operator who has arranged their window around a run // frequency does not necessarily want it moved every time they look at // someone else, and a spot already on screen stays where it is either way. Centre bool `json:"centre"` } // defaultFlexZoom — the widths DXHunter settled on, in kHz. var defaultFlexZoom = FlexZoomSettings{Enabled: false, CWkHz: 25, SSBkHz: 200, DigikHz: 50, Centre: false} // flexZoomGroup maps a spot's mode to the three widths worth distinguishing. // // The mode arrives as whatever the cluster comment implied — "CW", "FT8", // "USB", "RTTY" — so this takes the ADIF-ish name, not the Flex one. Anything // unrecognised is digital, which is the safe end: a 50 kHz window is readable // for every mode, where 25 kHz is not. func flexZoomGroup(mode string) string { switch strings.ToUpper(strings.TrimSpace(mode)) { case "CW", "CWR", "CWL", "CWU": return "cw" case "SSB", "USB", "LSB", "AM", "FM", "NFM", "DFM", "SAM", "PHONE", "DV": return "ssb" } return "digi" } // GetFlexZoom returns the settings, defaults included. func (a *App) GetFlexZoom() FlexZoomSettings { out := defaultFlexZoom if raw := a.settingOr(keyFlexZoom, ""); raw != "" { var s FlexZoomSettings if err := json.Unmarshal([]byte(raw), &s); err == nil { out = normFlexZoom(s) } } return out } // normFlexZoom clamps the widths to something a panadapter can actually show. // 1 kHz is narrower than most filters; 14 MHz is a whole HF spectrum. func normFlexZoom(s FlexZoomSettings) FlexZoomSettings { clamp := func(v, def int) int { if v < 1 || v > 14000 { return def } return v } s.CWkHz = clamp(s.CWkHz, defaultFlexZoom.CWkHz) s.SSBkHz = clamp(s.SSBkHz, defaultFlexZoom.SSBkHz) s.DigikHz = clamp(s.DigikHz, defaultFlexZoom.DigikHz) return s } // SaveFlexZoom persists the settings. func (a *App) SaveFlexZoom(s FlexZoomSettings) error { b, err := json.Marshal(normFlexZoom(s)) if err != nil { return err } a.setSetting(keyFlexZoom, string(b)) return nil } // FlexZoomForSpot resizes the panadapter for a spot that was just clicked. // // Called from the click handler, AFTER the rig has been tuned: the zoom is a // view change and must never delay the frequency change an operator is waiting // for. Does nothing at all unless the option is on and the backend is a Flex — // no other radio has a panadapter to zoom. func (a *App) FlexZoomForSpot(mode string, freqHz int64) { if a.cat == nil { return } z := a.GetFlexZoom() if !z.Enabled { return } khz := z.DigikHz switch flexZoomGroup(mode) { case "cw": khz = z.CWkHz case "ssb": khz = z.SSBkHz } _ = a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ZoomPan(float64(khz)/1000, float64(freqHz)/1e6, z.Centre) }) }