feat(relays): follow the entry Band selector when there is no CAT link

Relay automatic control and the band-change outbound rows both hang off the rig
state, which is right when there IS a rig: changing Band in the entry strip
pushes a QSY, the new state comes back through the CAT callback, and the relays
follow from there — which is why this works for anyone with rig control.

Without a CAT connection nothing is pushed and nothing comes back, so a station
whose rig OpsLog does not control changed band and the antenna switch sat
exactly where it was. Reported as automatic control not working; it was never
told the band had changed.

The entry selector now says so itself, but only when the CAT push did not
happen, and never when the band or frequency lock is on: a lock means the entry
is deliberately decoupled from the rig, and moving an antenna to match a contact
logged from last year is worse than doing nothing.

The frequency is passed as unknown on that path — a band selector gives a band
and nothing else, and rules written on a frequency RANGE are left alone rather
than evaluated against a made-up dial reading.

The de-duplication of "is this a new band" is now shared by both sources, so a
station that has both does not command its switch twice for one QSY.

Also: the same credit line as the QSL e-mail now closes the default recording
e-mail body, on the same terms — a default in the template, so a stored one is
untouched.
This commit is contained in:
2026-08-16 11:15:56 +02:00
parent c0dc1bfcc4
commit 423cf1f998
8 changed files with 131 additions and 16 deletions
+5 -1
View File
@@ -8771,7 +8771,11 @@ func (a *App) NetDeactivate(id int64) (int64, error) {
const ( const (
defaultEmailSubject = "Our QSO recording — {CALL}" defaultEmailSubject = "Our QSO recording — {CALL}"
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" // Same credit line as the QSL e-mail, and for the same reason it lives in the
// template rather than being appended at send time: it is a default, so an
// operator deletes it once, and one who already saved their own body never
// sees it — a stored template is returned verbatim.
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
) )
// EmailSettings is the user's SMTP config + auto-send + message templates. // EmailSettings is the user's SMTP config + auto-send + message templates.
+36
View File
@@ -0,0 +1,36 @@
package main
import "testing"
// A band change now has two sources: the rig, and the entry strip on a station
// whose rig OpsLog does not control. They share one memory of the last band, so
// a station that has both commands its antenna switch once per QSY rather than
// twice — the entry selector pushes a QSY, the rig reports the same band back,
// and only the first of the two is a change.
func TestBandChangeIsNotedOncePerBand(t *testing.T) {
lastTriggerBandMu.Lock()
lastTriggerBand = ""
lastTriggerBandMu.Unlock()
if b, changed := noteBandChange("20m"); !changed || b != "20m" {
t.Fatalf("first 20m = (%q,%v), want (\"20m\",true)", b, changed)
}
// The same band from the other source — the rig echoing the QSY back.
if _, changed := noteBandChange("20M"); changed {
t.Error("the rig echoing the band back counted as a second change — the switch would be commanded twice")
}
if _, changed := noteBandChange(" 20m "); changed {
t.Error("whitespace made the same band look new")
}
if b, changed := noteBandChange("40m"); !changed || b != "40m" {
t.Errorf("40m = (%q,%v), want (\"40m\",true)", b, changed)
}
// An unknown band must not be recorded, or the next real one would look
// unchanged against it.
if _, changed := noteBandChange(""); changed {
t.Error("an empty band was treated as a change")
}
if _, changed := noteBandChange("40m"); changed {
t.Error("the empty band overwrote the last one")
}
}
+4 -2
View File
@@ -5,12 +5,14 @@
"en": [ "en": [
"Right-click: update the US county of the selected contacts from the ULS database, replacing a county since renamed or abolished.", "Right-click: update the US county of the selected contacts from the ULS database, replacing a county since renamed or abolished.",
"A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.", "A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.",
"New installs: the default QSL e-mail ends with a credit line and a link to OpsLog. It is part of the template, so delete it if you'd rather not." "New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band."
], ],
"fr": [ "fr": [
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.", "Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
"Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre lenvoi dun upload automatique différé.", "Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre lenvoi dun upload automatique différé.",
"Nouvelles installations : le mail QSL par défaut se termine par une ligne de crédit et un lien vers OpsLog. Elle fait partie du modèle, supprimable." "Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne."
] ]
}, },
{ {
+11 -2
View File
@@ -18,7 +18,7 @@ import {
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactHeight, SetCompactMode, SetCompactHeight,
RotatorGoToPath, RotatorGoToPath,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor, RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
@@ -730,10 +730,19 @@ export default function App() {
function onBandUserChange(v: string) { function onBandUserChange(v: string) {
setBand(v); setBand(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected && !locks.band && !locks.freq) { // A lock means this value is deliberately decoupled from the rig (logging an
// old contact off-frequency) — drive nothing, neither the radio nor an antenna.
if (locks.band || locks.freq) return;
if (catState.enabled && catState.connected) {
const hz = qsyFreqHz(v, mode); const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {}); if (hz > 0) SetCATFrequency(hz).catch(() => {});
return; // the rig reports back, and the relays follow from its state
} }
// No rig to push to, so THIS is the band change. Without it, relay automatic
// control and the band-change outbound rows never heard about it — they only
// ever listened to the CAT state, which on a station without rig control
// never says anything.
EntryBandChanged(v).catch(() => {});
} }
function onModeUserChange(v: string) { function onModeUserChange(v: string) {
setMode(v); setMode(v);
+2
View File
@@ -196,6 +196,8 @@ export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function EntryBandChanged(arg1:string):Promise<void>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>; export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>; export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
+4
View File
@@ -334,6 +334,10 @@ export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
export function EntryBandChanged(arg1) {
return window['go']['main']['App']['EntryBandChanged'](arg1);
}
export function ExplainAward(arg1, arg2) { export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2); return window['go']['main']['App']['ExplainAward'](arg1, arg2);
} }
+31
View File
@@ -203,3 +203,34 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
} }
} }
// EntryBandChanged drives the band-following features from the QSO ENTRY band
// selector, for a station whose rig OpsLog does not control.
//
// Both of them — relay automatic control and the band-change outbound rows —
// hang off the rig state, and that is right when there IS a rig: changing Band
// in the entry strip pushes a QSY to it, the new state comes back through the
// CAT callback, and the relays follow from there. Without a CAT connection
// nothing is pushed and nothing comes back, so an operator with an antenna
// switch and no rig control changed band in OpsLog and watched the switch sit
// exactly where it was. Reported as automatic control not working; it was never
// told the band had changed.
//
// The frontend calls this ONLY when the CAT push did not happen, and never when
// the band or frequency lock is on — a lock means the entry is deliberately
// decoupled from the rig (logging an old contact off-frequency), and moving an
// antenna to match a QSO from last year is worse than doing nothing.
//
// The frequency is passed as unknown, deliberately: a band selector gives a
// band and nothing else, and rules written on a frequency RANGE must be left
// alone rather than evaluated against a made-up dial reading.
func (a *App) EntryBandChanged(band string) {
band = strings.TrimSpace(band)
if band == "" {
return
}
if a.relayAutoOn.Load() {
go a.applyRelayAuto(0, band)
}
a.emitBandChangeTrigger(band, "", 0)
}
+38 -11
View File
@@ -102,27 +102,54 @@ func (a *App) udpTriggerBandChange(s cat.RigState) {
if a.udp == nil { if a.udp == nil {
return return
} }
band := strings.ToLower(strings.TrimSpace(s.Band)) if !s.Connected {
if band == "" || !s.Connected {
return return
} }
lastTriggerBandMu.Lock() a.emitBandChangeTrigger(s.Band, s.Mode, s.FreqHz)
changed := band != lastTriggerBand }
if changed {
lastTriggerBand = band // noteBandChange normalises a band and reports whether it is a NEW one.
//
// The state is shared by both sources on purpose. The rig reports a band change
// and so does the entry strip, and on a station that has both, one QSY produces
// both — an antenna switch must be commanded once, not twice.
func noteBandChange(raw string) (string, bool) {
band := strings.ToLower(strings.TrimSpace(raw))
if band == "" {
return "", false
} }
lastTriggerBandMu.Unlock() lastTriggerBandMu.Lock()
defer lastTriggerBandMu.Unlock()
if band == lastTriggerBand {
return band, false
}
lastTriggerBand = band
return band, true
}
// emitBandChangeTrigger fires the band-change rows, once per NEW band.
//
// Split out of the rig-state path because the rig is not the only thing that
// changes band: a station with no CAT link changes it in the QSO entry strip,
// and that is just as much a band change to the antenna switch on the other end
// of the message. The de-duplication is shared, so the two sources cannot
// double-fire between them.
func (a *App) emitBandChangeTrigger(rawBand, mode string, freqHz int64) {
if a.udp == nil {
return
}
band, changed := noteBandChange(rawBand)
if !changed { if !changed {
return return
} }
f := map[string]string{ f := map[string]string{
"band": band, "band": band,
"band_m": bandMetres(band), "band_m": bandMetres(band),
"mode": s.Mode, "mode": mode,
} }
if s.FreqHz > 0 { if freqHz > 0 {
f["freq_hz"] = strconv.FormatInt(s.FreqHz, 10) f["freq_hz"] = strconv.FormatInt(freqHz, 10)
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(s.FreqHz)/1e6) f["freq_mhz"] = fmt.Sprintf("%.6f", float64(freqHz)/1e6)
} }
a.udp.EmitTrigger(udp.TriggerBandChange, f) a.udp.EmitTrigger(udp.TriggerBandChange, f)
} }