feat: panadapter spots that read, a CI-V link that survives, no auto-call

Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.

"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.

And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.

CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.

Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.

Also:
  - the log rotates while running, not only at startup: the CI-V trace left on
    wrote 416 MB and nothing would have stopped it before the disk did. Closing
    it now releases the crash file too — the runtime keeps its own duplicate.
  - the interface zoom announces itself, with a badge, a click back to 100% and
    a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
  - no more elastic bounce, and no swipe-to-navigate out of the app.
  - Edit QSO: your own TX power and the contacted station's extended locator
    were saved and written back with no box to set them.
  - FT decodes: continents are a multiple choice; a compound MSHV message that
    answers two stations in one line is recognised as addressed to you.
This commit is contained in:
2026-08-23 01:16:26 +02:00
parent bcfb3bfd37
commit f50806fbd9
17 changed files with 829 additions and 209 deletions
+109 -18
View File
@@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
// A colour the Flex refuses is a spot that never appears, and the command error
// goes to a log nobody is reading. Anything that is not #AARRGGBB must be
@@ -47,30 +50,118 @@ func TestNormSpotColors(t *testing.T) {
}
func TestSpotComment(t *testing.T) {
// The cluster's own text is kept: it carries the signal report and the
// operator's note, which is why the spot is readable in the first place.
if got := spotComment("CQ up 2", "new"); got != "CQ up 2 [NEW DXCC]" {
t.Fatalf("got %q", got)
// What the panadapter shows: the cluster's words, who spotted it, the entity,
// then what it is worth — DXHunter's order, so an operator reads one
// convention and not two.
got := spotComment("CQ up 2", "F4BPO", "Franz Josef Land", "new-slot")
want := "CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]"
if plain(got) != want {
t.Fatalf("got %q, want %q", plain(got), want)
}
// An empty comment must not produce a leading space on the panadapter.
if got := spotComment("", "new-band"); got != "[NEW BAND]" {
t.Fatalf("got %q", got)
// EVERY space is non-breaking, the ones inside the cluster comment included:
// SmartSDR splits its command line on spaces, so a real one truncates the
// comment at the first word.
if strings.Contains(got, " ") {
t.Fatalf("an ordinary space survived: %q", got)
}
// Nothing to say about a station already in the log.
if got := spotComment("loud", "worked"); got != "loud" {
t.Fatalf("got %q", got)
if !strings.Contains(got, " ") {
t.Fatal("no non-breaking space in the comment")
}
if got := spotComment("loud", "none"); got != "loud" {
t.Fatalf("got %q", got)
// Missing parts leave no empty brackets and no double separators.
if p := plain(spotComment("", "", "", "new")); p != "[New DXCC]" {
t.Fatalf("got %q", p)
}
// Every colourable status except the two quiet ones is labelled, or the
if p := plain(spotComment("CQ", "", "Spain", "")); p != "CQ [Spain]" {
t.Fatalf("got %q", p)
}
// A status with nothing to announce contributes no tag.
if p := plain(spotComment("loud", "DL1ABC", "Germany", "none")); p != "loud [DL1ABC] [Germany]" {
t.Fatalf("got %q", p)
}
// Every colourable status except the unresolved one is labelled, or the
// palette would say something the text does not.
for _, s := range spotColorOrder {
if s == "worked" || s == "none" {
for _, st := range spotColorOrder {
if st == "none" {
continue
}
if spotStatusTag(s) == "" {
t.Fatalf("status %q has a colour but no tag", s)
if spotStatusTag(st) == "" {
t.Fatalf("status %q has a colour but no tag", st)
}
}
}
// plain puts the ordinary spaces back, for readable assertions.
func plain(s string) string { return strings.ReplaceAll(s, " ", " ") }
// The per-status switch must survive a save with no colours on that row: it is
// an instruction in its own right, and dropping it would quietly turn the status
// back on. And a palette written before the switch existed must keep sending
// everything — "no field" is not "the operator turned this off".
func TestSpotColorHideSurvivesNormalisation(t *testing.T) {
in := SpotColors{Enabled: true, Colors: map[string]SpotColor{
"worked": {Hide: true}, // no colours, just the switch
"new": {Text: "#FFFF3B30", Hide: true}, // both
"none": {Text: "not a colour"}, // neither survives
"new-band": {Text: "#FFFFCC00"}, // colour only
}}
out := normSpotColors(in)
if c, ok := out.Colors["worked"]; !ok || !c.Hide {
t.Error("a status hidden with no colour was dropped")
}
if c := out.Colors["new"]; !c.Hide || c.Text != "#FFFF3B30" {
t.Errorf("hide and colour did not both survive: %+v", c)
}
if _, ok := out.Colors["none"]; ok {
t.Error("an entry with nothing valid in it was kept")
}
if c := out.Colors["new-band"]; c.Hide {
t.Error("a colour-only entry came back hidden")
}
// An older palette says nothing about hiding, and must send everything.
old := normSpotColors(SpotColors{Enabled: true, Colors: map[string]SpotColor{"new": {Text: "#FFFF3B30"}}})
if old.Colors["new"].Hide {
t.Error("a palette from before the switch existed came back hidden")
}
}
// SmartSDR cuts a long comment mid-word — one came back from the radio as
// "…[European Russia", losing its closing bracket and its status. What the
// operator would keep must therefore survive: the spotter, the entity and the
// status, with the cluster's own words trimmed to fit.
func TestSpotCommentFitsTheRadio(t *testing.T) {
long := "FT8 5 dB LN14 CQ DX FROM A VERY TALKATIVE SKIMMER NODE INDEED"
got := plain(spotComment(long, "OH6BG-#", "European Russia", "new-band"))
if len(got) > panCommentMax {
t.Fatalf("comment is %d chars, over the %d the radio keeps: %q", len(got), panCommentMax, got)
}
for _, must := range []string{"[OH6BG-#]", "[European Russia]", "[New Band]"} {
if !strings.Contains(got, must) {
t.Errorf("%s was trimmed away — the cluster text should go first: %q", must, got)
}
}
// A short comment is left exactly as it is.
short := plain(spotComment("CQ", "K1ABC", "Spain", "new"))
if short != "CQ [K1ABC] [Spain] [New DXCC]" {
t.Fatalf("got %q", short)
}
// No room at all for the cluster's words: they go, the brackets stay.
tight := plain(spotComment("CQ DX UP 2 LISTENING", "VERYLONGSPOTTER-#", "Democratic Republic of the Congo", "new"))
if strings.Contains(tight, "LISTENING") {
t.Errorf("kept the cluster text at the expense of the tags: %q", tight)
}
}
// RBN lines are column-aligned, so a comment arrives as "FT8 5 dB LN14
// CQ". Preserved as non-breaking spaces, those runs rendered on the panadapter
// as a gap wide enough to push the rest of the comment off the screen.
func TestSpotCommentCollapsesClusterPadding(t *testing.T) {
got := plain(spotComment("FT8 -13 dB CQ", "WC2L-#", "United States", "new"))
want := "FT8 -13 dB CQ [WC2L-#] [United States] [New DXCC]"
if got != want {
t.Fatalf("got %q\nwant %q", got, want)
}
// Tabs and newlines are padding too.
if p := plain(spotComment("CQ\t\tDX\n", "K1ABC", "", "")); p != "CQ DX [K1ABC]" {
t.Fatalf("got %q", p)
}
}