chore: release v0.25.9
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user