fix(sat,decodes): the left column holds position too, and drift stops lying

Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, slice B on 40 m, and
transmit focus on B, the 20 m decoder was told the rig was on 40 m while
the slice it listens to had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
This commit is contained in:
2026-09-10 15:25:54 +02:00
parent 3fe14c2c77
commit 7e6e1335e3
8 changed files with 240 additions and 94 deletions
+58 -12
View File
@@ -50,18 +50,28 @@ type interruptible interface {
// and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
// RxBands is every band the radio currently has a receiver on, Band
// included. One entry on a rig with one VFO; one per slice on a Flex.
//
// It exists because Band alone is the TRANSMIT band, and a station running
// two slices on two bands with a decoder on each is not drifting when one
// of them announces the band it is legitimately on. Reported for W4TE:
// slice A on 20 m with its own WSJT-X, slice B on 40 m holding transmit
// focus, and OpsLog telling him his 20 m decoder disagreed with a radio
// that was on 20 m — on that slice.
RxBands []string `json:"rx_bands,omitempty"`
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
@@ -1002,11 +1012,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
ns.RxBands = m.rxBands(ns)
m.update(ns)
}
}
}
// rxBands lists the bands the radio has a receiver on.
//
// Asked of the backend rather than derived from the state, because only the
// backend knows: a Flex reports its slices, and everything else has exactly
// one receiver whose band is already in the state. The transmit band is
// always included, even on a Flex, so this can never come back empty while
// the rig is on a frequency.
func (m *Manager) rxBands(st RigState) []string {
seen := map[string]bool{}
var out []string
add := func(b string) {
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
seen[b] = true
out = append(out, b)
}
}
add(st.Band)
if st.RxFreqHz != 0 {
add(BandFromHz(st.RxFreqHz))
}
if fx, ok := m.FlexState(); ok {
for _, sl := range fx.Slices {
if sl.Band != "" {
add(sl.Band)
continue
}
// A slice the radio has reported a frequency but no band for.
if sl.FreqHz != 0 {
add(BandFromHz(sl.FreqHz))
}
}
}
return out
}
func (m *Manager) applyCommandDelay() {
m.mu.RLock()
d := m.cmdDelay
+66
View File
@@ -0,0 +1,66 @@
package cat
import (
"strings"
"testing"
)
// RxBands is what stops the decode panel's band-drift warning from lying to a
// station running two slices.
//
// Reported for W4TE: slice A on 20 m with its own WSJT-X, slice B on 40 m
// holding transmit focus. RigState.Band is the TRANSMIT band, so the 20 m
// decoder was told "the rig is on 40M" while the slice it listens to had been
// on 20 m the whole time.
func TestRxBandsAlwaysCarriesTheTransmitBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m"})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
}
// A split rig receives on one band and transmits on another — cross-band split
// is unusual but legal, and the receive side is where a decoder listens.
func TestRxBandsIncludesTheSplitReceiveBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m", RxFreqHz: 7074000, Split: true})
if !has(got, "20m") || !has(got, "40m") {
t.Fatalf("got %v, want both 20m and 40m", got)
}
}
// Nothing to report when the rig is on no frequency: an empty list means "there
// is nothing to compare with", and the panel treats it as such rather than as
// "the radio is on no band", which would warn about every decode.
func TestRxBandsIsEmptyWithNoFrequency(t *testing.T) {
m := &Manager{}
if got := m.rxBands(RigState{}); len(got) != 0 {
t.Fatalf("got %v, want nothing", got)
}
}
// Deduplicated and lower-cased, because the panel compares strings: two slices
// on the same band are one band, and "20M" from a backend must match "20m" from
// BandFromHz.
func TestRxBandsIsNormalised(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20M", RxFreqHz: 14080000})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
for _, b := range got {
if b != strings.ToLower(b) {
t.Errorf("%q is not lower-cased", b)
}
}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}