diff --git a/app.go b/app.go index 5511d2d..68d873c 100644 --- a/app.go +++ b/app.go @@ -11880,6 +11880,21 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { return 0, fmt.Errorf("record missing required fields (call/band/mode/date)") } + // The grid the station SENT beats any directory. + // + // A CQ in FT8 carries the operator's square, and it is where they are right + // now. QRZ and HamQTH describe where they LIVE, which for an expedition is + // the wrong side of the planet: RI0FA transmitted QN35 all evening and was + // logged with its licence-holder's home square, because WSJT-X's ADIF carries + // no GRIDSQUARE and the lookup was the only source left. + // + // Applied BEFORE the lookup, so refineGrid below sees a grid it must not + // replace. It still upgrades a 4-character square to a 6-character one from + // the same field. + if g := a.lookupDecodeGrid(q.Callsign); g != "" { + q.Grid = refineGrid(q.Grid, g) + } + // ── Lookup-based enrichment ── // WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/ // Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/ diff --git a/changelog.json b/changelog.json index 511a9d2..a960554 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,17 @@ "Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.", "The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.", "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.", - "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded." + "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.", + "FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.", + "SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF." ], "fr": [ "Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.", "La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.", "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.", - "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés." + "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.", + "FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.", + "Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF." ] }, { diff --git a/internal/spe/poweroff_test.go b/internal/spe/poweroff_test.go new file mode 100644 index 0000000..c4f7665 --- /dev/null +++ b/internal/spe/poweroff_test.go @@ -0,0 +1,54 @@ +package spe + +import ( + "testing" + "time" +) + +// decodeCSV marks the client connected on EVERY frame it parses, and the poll +// goroutine can be mid-read while PowerOff runs. So the frame from a second ago +// landed after PowerOff had marked the amp offline and put it straight back — +// the console kept showing the amplifier on until the operator pressed OFF a +// second time. +func TestFrameInFlightDoesNotResurrectASwitchedOffAmp(t *testing.T) { + c := &Client{} + const frame = ",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N," + + // Normal running: a frame marks it connected and reads OPERATE. + c.decodeCSV(frame) + if !c.GetStatus().Connected || !c.GetStatus().Operate { + t.Fatal("a status frame should mark the amp connected") + } + + // The operator presses OFF. This is what PowerOff does, in order. + c.statusMu.Lock() + c.offUntil = time.Now().Add(3 * time.Second) + c.statusMu.Unlock() + c.setErr(errSwitchedOff{}) + + // A frame that was already on the wire arrives now. + c.decodeCSV(frame) + + if c.GetStatus().Connected { + t.Error("a frame from before the switch-off put the amp back on — this is the double-click on OFF") + } +} + +// An amp switched back on at its own front panel must reappear without OpsLog +// being told, so the suppression has to expire rather than latch. +func TestSuppressionExpires(t *testing.T) { + c := &Client{} + c.statusMu.Lock() + c.offUntil = time.Now().Add(-time.Second) // window already past + c.statusMu.Unlock() + c.setErr(errSwitchedOff{}) + + c.decodeCSV(",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,") + if !c.GetStatus().Connected { + t.Error("an amp answering after the window must come back on its own") + } +} + +type errSwitchedOff struct{} + +func (errSwitchedOff) Error() string { return "switched off" } diff --git a/internal/spe/spe.go b/internal/spe/spe.go index 95b8091..b951d42 100644 --- a/internal/spe/spe.go +++ b/internal/spe/spe.go @@ -91,6 +91,15 @@ type Client struct { statusMu sync.RWMutex status Status lastRaw string // last raw status payload logged (log only on change) + // offUntil suppresses status frames that were already in flight when the amp + // was switched off. decodeCSV marks the client connected on every frame it + // parses, and the poll goroutine can be mid-read while PowerOff runs — so the + // frame from a second ago resurrected an amplifier the operator had just + // switched off, and the console only caught up when they pressed OFF twice. + // + // Time-bounded rather than a latch: an amp switched back on at its own front + // panel must reappear on its own. + offUntil time.Time stop chan struct{} running bool @@ -177,6 +186,12 @@ func (c *Client) PowerOn() error { if c.GetStatus().Connected { return nil } + // Switching on cancels the suppression window: the operator wants frames + // believed again, and waiting out a timer they cannot see would read as the + // power-on having failed. + c.statusMu.Lock() + c.offUntil = time.Time{} + c.statusMu.Unlock() // The poll goroutine may still be inside a blocking read on the old handle; // Windows keeps the port "busy" until that read times out (ioTimeout). Retry // the open for a little longer than that. @@ -226,6 +241,14 @@ func (c *Client) PowerOn() error { // the poll loop keeps reopening the port with both lines high and that has // never woken it — only the deliberate low→high sequence in PowerOn does. func (c *Client) PowerOff() error { + // Close the window BEFORE the key goes out, so a frame already travelling up + // the wire cannot land after setErr and undo it. Three seconds covers a poll + // interval with room to spare; after that a real answer means the amp is + // genuinely alive again, which is what happens if it is switched back on at + // its own front panel. + c.statusMu.Lock() + c.offUntil = time.Now().Add(3 * time.Second) + c.statusMu.Unlock() err := c.sendCmd(cmdOff) applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err) // Drop the link and mark the amp offline at once, exactly as a fresh start @@ -482,6 +505,9 @@ func (c *Client) decodeCSV(payload string) { c.lastRaw = payload applog.Printf("spe: status raw=%q fields=%d", payload, len(f)) } + if time.Now().Before(c.offUntil) { + return // a frame from before the switch-off — the amp is on its way down + } c.status.Connected = true c.status.LastError = "" // The real frame carries a leading empty field (it starts with a comma), so the diff --git a/udp_grid_test.go b/udp_grid_test.go new file mode 100644 index 0000000..cfa8d9b --- /dev/null +++ b/udp_grid_test.go @@ -0,0 +1,47 @@ +package main + +import "testing" + +// A CQ in FT8 carries the operator's square and it is where they are NOW. +// QRZ and HamQTH describe where they LIVE, which for an expedition is the +// wrong side of the planet — RI0FA transmitted QN35 all evening and was logged +// with its licence-holder's home square. +// +// refineGrid is the referee. What it must never do is let a directory value +// replace one heard on the air. +func TestRefineGridKeepsWhatWasHeardOnAir(t *testing.T) { + for _, tc := range []struct{ have, found, want string }{ + // The expedition case: heard QN35, directory says the home square. + {"QN35", "KO85", "QN35"}, + // Nothing heard: the directory is all there is. + {"", "KO85", "KO85"}, + // A finer square from the SAME field is an upgrade, not a contradiction. + {"QN35", "QN35SL", "QN35SL"}, + // A finer square from a DIFFERENT field is a contradiction — refuse it. + {"QN35", "KO85AB", "QN35"}, + // Case and padding must not decide anything. + {" qn35 ", "KO85", "QN35"}, + {"QN35", "", "QN35"}, + } { + if got := refineGrid(tc.have, tc.found); got != tc.want { + t.Errorf("refineGrid(%q, %q) = %q, want %q", tc.have, tc.found, got, tc.want) + } + } +} + +// And the ordering that makes it work: the decoded grid has to be applied +// BEFORE the lookup, or refineGrid has nothing to defend. +func TestDecodedGridWinsOverTheDirectory(t *testing.T) { + a := &App{} + a.rememberDecodeGrid("RI0FA", "QN35", "decode") + + grid := "" // WSJT-X's ADIF carried no GRIDSQUARE, which is the usual case + if g := a.lookupDecodeGrid("RI0FA"); g != "" { + grid = refineGrid(grid, g) + } + grid = refineGrid(grid, "KO85") // then the lookup answers with the home square + + if grid != "QN35" { + t.Errorf("logged grid = %q, want QN35 — the square the station actually sent", grid) + } +}