Files
OpsLog/flexzoom.go
T
2026-08-21 23:28:55 +02:00

123 lines
4.0 KiB
Go

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 <pan> bandwidth=<MHz>", 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)
})
}