chore: release v0.25.9

This commit is contained in:
2026-08-18 05:09:04 +02:00
parent a81125eab1
commit 9599c3e0b9
18 changed files with 962 additions and 67 deletions
+271 -18
View File
@@ -114,6 +114,7 @@ const (
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
keyCATFlexDVKDax = "cat.flex.dvk_dax" // raise the transmit-bar DAX button while a voice message plays
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
keyCATPollMs = "cat.poll_ms"
@@ -245,6 +246,9 @@ const (
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
keyRowColors = "appearance.row_colors"
keyRotorPresets = "rotator.presets" // quick-turn buttons on the rotor widget
keyCATOffsetHz = "cat.offset_hz" // transverter offset: real frequency rig frequency
keyCATOffsetOn = "cat.offset_on" // "1" → apply the transverter offset
keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
@@ -427,6 +431,7 @@ type CATSettings struct {
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
FlexDVKDax bool `json:"flex_dvk_dax"` // raise transmit DAX while a voice message plays, lower it after
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
@@ -448,21 +453,26 @@ type CATSettings struct {
// stops some USB-serial interfaces transmitting at all.
YaesuLowLines bool `json:"yaesu_low_lines"`
KenwoodLowLines bool `json:"kenwood_low_lines"`
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
// Transverter offset: real frequency rig frequency, in Hz. A 28 MHz IF
// driving a 144 MHz transverter is +116000000. Kept as a separate on/off so
// the number survives switching the transverter out of line for an evening.
OffsetOn bool `json:"offset_on"`
OffsetHz int64 `json:"offset_hz"`
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
// ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
// not both: they are two ways of asking the same radio the same questions,
// and a second listener is only a second thing to go wrong.
@@ -740,6 +750,7 @@ type App struct {
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
flexDaxRaised bool // we pressed the Flex transmit DAX button for it; restore when it ends
pttMu sync.Mutex
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
// so closing the QSL Manager — or starting another download — stops the previous
@@ -774,6 +785,7 @@ type App struct {
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
@@ -1400,6 +1412,10 @@ func (a *App) startup(ctx context.Context) {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// And give the microphone back, if we took it (see raiseFlexDVKDax).
// Off the callback's goroutine: this talks to the radio, and the
// audio manager is reporting a state change, not waiting on us.
go a.lowerFlexDVKDax()
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
@@ -7508,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
if err != nil {
return CATSettings{}, err
}
@@ -7519,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
FlexDVKDax: m[keyCATFlexDVKDax] == "1",
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
FlexDecodeSecs: 120,
XieguPort: m[keyCATXieguPort],
@@ -7547,6 +7564,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
PollMs: 250,
DelayMs: 0,
OffsetOn: m[keyCATOffsetOn] == "1",
DigitalDefault: m[keyCATDigitalDefault],
ShareEnabled: m[keyCATShareEnabled] == "1",
SharePort: 4532,
@@ -7556,6 +7574,11 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
// Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a
// 28 MHz receive converter is a negative offset).
if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil {
out.OffsetHz = n
}
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
out.FlexDecodeSecs = n
}
@@ -7666,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots {
flexSpots = "1"
}
flexDVKDax := "0"
if s.FlexDVKDax {
flexDVKDax = "1"
}
offsetOn := "0"
if s.OffsetOn {
offsetOn = "1"
}
flexDecodeSpots := "0"
if s.FlexDecodeSpots {
flexDecodeSpots = "1"
@@ -7702,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATFlexDVKDax: flexDVKDax,
keyCATFlexDecodeSpots: flexDecodeSpots,
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
@@ -7731,6 +7763,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATOffsetOn: offsetOn,
keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
keyCATShareEnabled: shareEnabled,
keyCATSharePort: strconv.Itoa(s.SharePort),
@@ -8591,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error {
}
cfg := cfgEarly
// Same as the voice keyer: this goes out over the air, so on a Flex the
// transmit source has to be DAX before the carrier comes up.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
// Keep going — the audio still reaches the rig and the operator may use VOX.
@@ -8609,6 +8646,7 @@ func (a *App) QSOAudioPlayOnAir() error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
return err
}
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
@@ -9529,6 +9567,10 @@ func (a *App) DVKPlay(slot int) error {
return fmt.Errorf("no recording in slot %d", slot)
}
cfg, _ := a.GetAudioSettings()
// Before PTT: on a Flex the transmit audio comes from the microphone or from
// DAX, so the source has to be right before the carrier goes up — otherwise
// the first syllables are transmitted as silence.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
@@ -9546,11 +9588,77 @@ func (a *App) DVKPlay(slot int) error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// Nothing is going out, so the microphone must come straight back — the
// status callback that normally does it never fires for a play that
// never started.
a.lowerFlexDVKDax()
return err
}
return nil
}
// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice
// message goes out, and remembers whether it was already down.
//
// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX,
// never both. A voice keyer plays into the DAX TX stream, so with the button up
// the message is transmitted as silence; with it left down afterwards the
// operator's own microphone is dead. Pressing it by hand around every message is
// exactly the kind of thing that gets forgotten mid-pileup, hence the option.
//
// The PREVIOUS state is what gets restored, not a blanket "off": an operator
// running WSJT-X has DAX down on purpose, and lowering it for them after a voice
// message would break the digital setup to solve a problem they do not have. In
// the case this option exists for — microphone operating, DAX up — restoring the
// previous state is precisely "give me my microphone back".
//
// Silent no-op when the option is off or the radio is not a Flex: this sits on
// the voice-keyer path, which must not fail because a rig cannot do DAX.
func (a *App) raiseFlexDVKDax() {
if a.cat == nil || !a.catFlexDVKDax {
return
}
var was bool
err := a.cat.FlexDo(func(fc cat.FlexController) error {
was = fc.FlexState().TXDAX
if was {
return nil // already down — nothing to press, nothing to restore
}
return fc.SetTXDAX(true)
})
if err != nil {
applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err)
return
}
if was {
return
}
a.pttMu.Lock()
a.flexDaxRaised = true
a.pttMu.Unlock()
applog.Printf("dvk: Flex TX DAX on for the voice message")
}
// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only
// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is
// released, so the microphone comes back at the moment the message ends.
func (a *App) lowerFlexDVKDax() {
a.pttMu.Lock()
raised := a.flexDaxRaised
a.flexDaxRaised = false
a.pttMu.Unlock()
if !raised || a.cat == nil {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil {
// Worth saying loudly: the microphone stays dead until it is pressed by
// hand, and an operator whose voice is not going out has no other clue.
applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err)
return
}
applog.Printf("dvk: Flex TX DAX off — microphone back on air")
}
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
@@ -11244,6 +11352,12 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
done(matched, total)
return
}
// Second pass, at mode-CLASS granularity: LoTW answers with the mode
// GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2
// contact can never match on the exact string and the operator is told
// their own QSO is missing from their log.
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
byClass := 0
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
@@ -11263,8 +11377,20 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)
classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)
id, found := keyIDs[key]
if !found {
// The mode differs but the station, minute, band and mode class
// agree — the same contact under LoTW's group name. An ambiguous
// class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs.
if cid, ok := classIDs[classKey]; ok && cid != 0 {
id, found = cid, true
byClass++
}
}
if found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
@@ -11274,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
classIDs[classKey] = newID
added++
}
} else {
@@ -11324,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
if byClass > 0 {
// Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
// every digital submode, so this is the normal case for FT4/FT8 — but
// an operator reconciling a log deserves to know the match was looser
// than call+minute+band+mode.
emit(fmt.Sprintf(" (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass))
}
// Surface confirmations with no local match so the user sees WHICH one
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
// never logged). Tick "Add not-found" to import them instead.
@@ -14014,9 +14149,18 @@ func (a *App) reloadCAT() {
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
// Transverter offset. Zero when switched off — the manager treats "no offset"
// and "disabled" as the same thing, so the number stays in the settings for
// the next time the transverter goes back in line.
if s.OffsetOn {
a.cat.SetFreqOffset(s.OffsetHz)
} else {
a.cat.SetFreqOffset(0)
}
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
a.reloadCATShare(s)
if !s.Enabled {
a.cat.Stop()
@@ -14896,6 +15040,115 @@ func (a *App) RotatorStop() error {
}
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and
// the azimuth it swings to.
type RotorPreset struct {
Label string `json:"label"`
Azimuth int `json:"azimuth"`
}
// rotorPresetMax bounds both the number of buttons and the label length. The
// widget lays them out in a fixed grid beside the dial, and a label that does not
// fit is a button whose meaning is guessed rather than read.
const (
rotorPresetMax = 8
rotorPresetLabelMax = 6
)
// rotorPresetRegions are the out-of-the-box buttons, each with a representative
// point rather than a ready-made azimuth.
//
// The bearing is COMPUTED from the operator's own square. A bearing is only
// meaningful from somewhere: a table of degrees that suits one station points
// every other one at the wrong continent, and "EU 090" is worse than no default
// at all for a station in Japan. With no square known they come out at 0 and the
// operator fills them in — which is what the editor in Settings is for.
var rotorPresetRegions = []struct {
label string
lat, lon float64
}{
{"EU", 50, 15}, // central Europe
{"NA", 40, -95}, // central United States
{"SA", -15, -60}, // Brazil
{"VK", -25, 135}, // central Australia
{"JA", 36, 138}, // Japan
{"AF", 0, 20}, // central Africa
}
// defaultRotorPresets builds the six regions as seen from the operator's grid.
func (a *App) defaultRotorPresets() []RotorPreset {
out := make([]RotorPreset, 0, len(rotorPresetRegions))
for _, r := range rotorPresetRegions {
az := 0
if a.opSet {
az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5)
}
out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360})
}
return out
}
// normRotorPresets keeps the list within what the widget can draw and the rotor
// can accept. A blank label drops the button entirely — that is how an operator
// removes one without a delete control.
func normRotorPresets(in []RotorPreset) []RotorPreset {
out := make([]RotorPreset, 0, len(in))
for _, p := range in {
label := strings.TrimSpace(p.Label)
if label == "" {
continue
}
if len(label) > rotorPresetLabelMax {
label = label[:rotorPresetLabelMax]
}
az := p.Azimuth % 360
if az < 0 {
az += 360
}
out = append(out, RotorPreset{Label: label, Azimuth: az})
if len(out) == rotorPresetMax {
break
}
}
return out
}
// GetRotorPresets returns the quick-turn buttons, falling back to the regions
// computed from the operator's square so the widget is useful before anyone
// visits the settings.
func (a *App) GetRotorPresets() []RotorPreset {
if raw := a.settingOr(keyRotorPresets, ""); raw != "" {
var saved []RotorPreset
if err := json.Unmarshal([]byte(raw), &saved); err == nil {
// A saved-but-empty list is a deliberate "no buttons", not a missing
// setting — otherwise clearing them all would resurrect the defaults.
return normRotorPresets(saved)
}
}
return a.defaultRotorPresets()
}
// SaveRotorPresets persists the buttons.
func (a *App) SaveRotorPresets(list []RotorPreset) error {
b, err := json.Marshal(normRotorPresets(list))
if err != nil {
return err
}
a.setSetting(keyRotorPresets, string(b))
return nil
}
// ResetRotorPresets puts back the six regions, recomputed from the operator's
// current square — the way to fix them after moving, or after a first run with
// no grid set.
func (a *App) ResetRotorPresets() []RotorPreset {
def := a.defaultRotorPresets()
if b, err := json.Marshal(def); err == nil {
a.setSetting(keyRotorPresets, string(b))
}
return def
}
// RotatorPark moves the active rotor to its parked position (PstRotator only;
// the 4O3A native and ARCO GS-232 protocols have no park command).
func (a *App) RotatorPark() error {