fix(tci): pick the sideband from the frequency just commanded

Clicking a spot sets the frequency, then the mode 150 ms later, and SetMode
chose USB or LSB from t.freqA — the frequency the radio had ECHOED. From 7 MHz
onto a 14 MHz spot that echo has often not landed, so the sideband was computed
for the band being left: LSB on 20 m. Clicking the same spot again, the echo
having arrived, gave USB. Reported from a SunSDR as 'the frequency is right, the
mode is wrong until I click twice'.

SetFrequency now records what was asked for and SetMode prefers it until the
radio's own echo confirms the move.

The server's 'protocol:' announcement is also kept instead of being filed under
unhandled: it names the ExpertSDR and its TCI version, and panorama spots need
TCI 1.5 — older servers accept the spot commands and silently drop them, which
is indistinguishable from a broken logger. Now it says so.
This commit is contained in:
2026-08-28 12:42:05 +02:00
parent 08d6492df1
commit d534825e92
3 changed files with 81 additions and 2 deletions
+53
View File
@@ -27,6 +27,12 @@ type TCI struct {
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
// wantFreq is the frequency last COMMANDED and not yet echoed back, used to
// pick the sideband before the radio has confirmed the move.
wantFreq int64
// What the server said it is, from its "protocol:" announcement.
serverName string
serverVersion string
// OnSpotClick is called when the user clicks one of our spots on the TCI
// panorama (callsign + freq), so the host can fill the entry form. Set before
@@ -334,6 +340,11 @@ func (t *TCI) ReadState() (RigState, error) {
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
// Remember what we ASKED for. SetMode reads it to choose the sideband, and
// the radio's own echo can be a moment behind — see SetMode.
t.mu.Lock()
t.wantFreq = hz
t.mu.Unlock()
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
@@ -342,6 +353,17 @@ func (t *TCI) SetFrequency(hz int64) error {
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
// Prefer the frequency we just COMMANDED over the one the radio has echoed.
//
// Clicking a spot sets the frequency and then the mode, and the sideband is
// chosen from the frequency (below 10 MHz → LSB). Read from the echo, that
// is the frequency we were on BEFORE the click whenever the echo has not
// landed yet: a 14 MHz spot clicked from 7 MHz got LSB, and clicking the same
// spot again — now that the echo has arrived — got USB. Reported from a
// SunSDR as "the frequency is right, the mode is wrong until I click twice".
if t.wantFreq > 0 {
freq = t.wantFreq
}
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
@@ -493,6 +515,18 @@ func (t *TCI) handle(msg string) {
switch lower {
case "device":
t.device = strings.TrimSpace(args)
// The server's own announcement: "protocol:ExpertSDR3,1.9;" — its name and
// the TCI version it speaks. Worth keeping rather than filing under
// "unhandled": panorama spots need a version that HAS the spot command, and
// without this an operator on an older ExpertSDR sees nothing on the
// waterfall and nothing anywhere saying why.
case "protocol":
t.serverName, t.serverVersion = get(0), get(1)
debugLog.Printf("TCI: server is %s, TCI %s", t.serverName, t.serverVersion)
if t.spotsEnabled && tciSpotsUnsupported(t.serverVersion) {
debugLog.Printf("TCI: this server speaks TCI %s — panorama spots need 1.5 or later, so they will not appear",
t.serverVersion)
}
// The radio ANNOUNCES its audio format at connect —
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
// is better evidence than anything derived from a frame, and it arrives
@@ -516,6 +550,10 @@ func (t *TCI) handle(msg string) {
switch get(1) {
case "0":
t.freqA = hz
// The radio has caught up: from here the echo IS the truth.
if t.wantFreq != 0 && absInt64(hz-t.wantFreq) < 100 {
t.wantFreq = 0
}
case "1":
t.freqB = hz
}
@@ -651,3 +689,18 @@ func adifToTCIMode(mode string, freqHz int64) string {
return "digu"
}
}
// tciSpotsUnsupported reports whether a TCI version predates the spot commands.
//
// SPOT / SPOT_DELETE / SPOT_CLEAR arrived in TCI 1.5. An older ExpertSDR accepts
// the connection, answers frequency and mode perfectly, and silently ignores
// every spot — which is indistinguishable from a bug in the logger unless
// somebody says so. Anything unparseable is treated as supported: refusing to
// draw on a doubt would be the worse mistake.
func tciSpotsUnsupported(version string) bool {
var maj, min int
if n, err := fmt.Sscanf(strings.TrimSpace(version), "%d.%d", &maj, &min); n < 2 || err != nil {
return false
}
return maj < 1 || (maj == 1 && min < 5)
}
+24
View File
@@ -0,0 +1,24 @@
package cat
import "testing"
func TestTCISpotsUnsupported(t *testing.T) {
// SPOT arrived in TCI 1.5. The SunSDR2 PRO report that prompted this was an
// ExpertSDR announcing 1.3 — spots accepted and silently dropped.
for _, c := range []struct {
version string
old bool
}{
{"1.3", true},
{"1.4", true},
{"1.5", false},
{"1.9", false},
{"2.0", false},
{"", false}, // unparseable → assume it works
{"weird", false}, // refusing to draw on a doubt is the worse mistake
} {
if got := tciSpotsUnsupported(c.version); got != c.old {
t.Errorf("tciSpotsUnsupported(%q) = %v, want %v", c.version, got, c.old)
}
}
}