fix(tci): one marker per callsign on the panorama, not one per spotter

Spot mirroring drew the same station two or three times, a few hertz apart.

Not a loop in the spot pipeline — one cluster line produces exactly one
SendSpot. It is that a popular DX station IS spotted two or three times, by
different operators inside the same minute, and no two of them agree on the
frequency to better than a few tens of hertz. This backend assumed ExpertSDR
replaced a spot bearing a callsign it already held, and said so in a comment;
it keys on the frequency too, so each of those became its own marker and stayed.

The FlexRadio backend has always done this properly (spot remove before spot
add, "one live spot per call"). TCI now does the same: the frequency drawn for
each call is remembered, a re-spot within 500 Hz sends nothing at all, and one
further away deletes the old marker before drawing the new one — so a station
that really moves still moves, instead of collecting markers.

SPOT_DELETE was not in the protocol document this backend was written from.
Confirmed before use against ars-ka0s/eesdr-tci, an independent TCI library that
lists SPOT with 5 arguments, SPOT_DELETE with 1 and SPOT_CLEAR with 0 — the
other two matching exactly what already works here, which is what makes the
third trustworthy.

The memory is cleared where Connect sends spot_clear. Left standing, it would
suppress the next spot for every remembered call as "already drawn" onto a
panorama the radio had just emptied — spots would quietly stop appearing after
a reconnect until each station changed frequency.

Five tests on the decision, which is split out from SendSpot so it can be
checked without a radio.
This commit is contained in:
2026-08-17 01:57:11 +02:00
parent 91b21a4a36
commit 491546c7f4
3 changed files with 168 additions and 4 deletions
+87
View File
@@ -0,0 +1,87 @@
//go:build windows
package cat
import "testing"
// The reported symptom: with spot mirroring on, the same station appeared two
// or three times on the panorama.
//
// Its cause is not in OpsLog's spot pipeline — one cluster line produces one
// SendSpot. It is that a popular DX station IS spotted two or three times, by
// different operators within the same minute, and no two of them agree on the
// frequency to better than a few tens of hertz. The backend assumed ExpertSDR
// replaced a spot bearing a callsign it already had; it keys on the frequency
// too, so each of those became its own marker.
func TestSameStationSpottedBySeveralOperatorsIsDrawnOnce(t *testing.T) {
tci := &TCI{spotsEnabled: true}
draw, del := tci.noteSpot("UN7GK", 14025000)
if !draw || del {
t.Fatalf("first spot: draw=%v delete=%v, want draw and nothing to delete", draw, del)
}
// The same station, two more spotters, a few tens of hertz apart.
for _, hz := range []int64{14025120, 14024900} {
if draw, del := tci.noteSpot("UN7GK", hz); draw || del {
t.Errorf("re-spot at %d Hz: draw=%v delete=%v, want nothing sent — this is the duplicate marker", hz, draw, del)
}
}
}
// A station that really moves must still move on the panorama, and the marker
// left where it was must go. Deleting first is the whole difference between
// "the spot follows the station" and "the station collects markers".
func TestAStationThatMovesReplacesItsMarker(t *testing.T) {
tci := &TCI{spotsEnabled: true}
tci.noteSpot("UN7GK", 14025000)
draw, del := tci.noteSpot("UN7GK", 14032000) // 7 kHz up: a real QSY
if !draw || !del {
t.Fatalf("after a QSY: draw=%v delete=%v, want the old marker deleted and a new one drawn", draw, del)
}
// And the new position becomes the reference, so spotters agreeing with it
// are quiet again.
if draw, _ := tci.noteSpot("UN7GK", 14032100); draw {
t.Error("a spot at the station's new frequency was drawn again")
}
}
// Case matters nowhere in ham radio, and the cluster is not consistent about it.
func TestSpotMemoryIgnoresCase(t *testing.T) {
tci := &TCI{spotsEnabled: true}
tci.noteSpot("un7gk", 14025000)
if draw, _ := tci.noteSpot("UN7GK", 14025000); draw {
t.Error("the same call in another case was treated as a different station")
}
}
// Two different stations are two markers — the whole point of the panorama.
func TestDifferentStationsEachGetAMarker(t *testing.T) {
tci := &TCI{spotsEnabled: true}
tci.noteSpot("UN7GK", 14025000)
draw, del := tci.noteSpot("ZD7BG", 14025050) // 50 Hz away, a different operator
if !draw {
t.Error("a second station near the first was swallowed as a duplicate")
}
if del {
t.Error("deleting by callsign would have removed a spot this station never had")
}
}
// The connection drops and comes back: Connect sends spot_clear, so the
// panorama is empty. If the memory survived that, the next spot for each of
// those calls would be suppressed as "already drawn" onto an empty panorama —
// the operator's spots would simply stop appearing until they changed
// frequency.
func TestReconnectingForgetsWhatWasDrawn(t *testing.T) {
tci := &TCI{spotsEnabled: true}
tci.noteSpot("UN7GK", 14025000)
tci.mu.Lock()
tci.spotFreq = map[string]int64{} // what Connect does alongside spot_clear
tci.mu.Unlock()
if draw, del := tci.noteSpot("UN7GK", 14025000); !draw || del {
t.Errorf("after a reconnect: draw=%v delete=%v, want it drawn again and nothing deleted", draw, del)
}
}