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
+67 -17
View File
@@ -8708,15 +8708,20 @@ func (a *App) clusterEventWorker() {
// grid uses, so a spot is never one colour on screen and another on
// the radio — and the index is cached, so this costs a map lookup.
status := a.spotStatusName(s.DXCall, s.Band, mode, s.POTARef)
col := a.spotColorFor(status)
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Mode: mode,
Comment: spotComment(s.Comment, status),
Color: col.Text,
BackgroundColor: col.Bg,
})
// A status the operator has switched off never reaches the radio. A
// skipped SEND rather than a transparent colour: a spot that is not
// drawn should not occupy one of the panadapter's slots either.
if !a.spotHidden(status) {
col := a.spotColorFor(status)
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Mode: mode,
Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
Color: col.Text,
BackgroundColor: col.Bg,
})
}
}
}
}
@@ -13144,6 +13149,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// (same reasoning as the manual path — award_refs on a busy shared MySQL blocked).
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qso:logged", id)
// A contact logged by WSJT-X / JTDX / MSHV is the one the operator has no
// other confirmation of: the entry form did not clear, because they never
// typed in it, and on the FT decodes tab the log grid is not even on
// screen. Its own event, carrying enough to name the station — qso:logged
// is an id, and fires twice per contact, so it can drive a refresh but not
// a message.
wruntime.EventsEmit(a.ctx, "qso:logged_external", map[string]any{
"call": q.Callsign, "band": q.Band, "mode": q.Mode, "country": q.Country,
})
}
qc := q
go func() {
@@ -13313,19 +13327,37 @@ func (a *App) consumeUDPEvents() {
if len(lastDecodeSpot) > 4000 {
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
}
lastDecodeSpot[key] = now
secs := a.catFlexDecodeSecs
if secs <= 0 {
secs = 120
}
// The palette governs the PANADAPTER, not one of the two things
// that feed it. A decode spot is a callsign on the waterfall
// exactly as a cluster spot is, so it answers to the same
// per-status switch and the same colours — an operator who turned
// "entity not resolved" off and still watched unresolved calls
// pile up on the radio was right to call that a bug.
status := a.spotStatusName(ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode, "")
if a.spotHidden(status) {
continue
}
lastDecodeSpot[key] = now
// Green when the palette is off — the colour this feature has always
// used, and what still tells a decode from a cluster spot.
col := a.spotColorFor(status)
textCol, bgCol := col.Text, col.Bg
if textCol == "" {
textCol, bgCol = "#FF34C759", ""
}
select {
case spotQ <- cat.SpotInfo{
FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall,
Mode: ev.Mode,
Color: "#FF34C759", // green — distinct from cluster orange
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
LifetimeSec: secs,
FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall,
Mode: ev.Mode,
Color: textCol,
BackgroundColor: bgCol,
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
LifetimeSec: secs,
}:
default:
// Radio not keeping up: skip the spot rather than stall the
@@ -19439,7 +19471,12 @@ func (a *App) spotStatusName(call, band, mode, potaRef string) string {
case r.NewPfx:
return "new-pfx"
}
if r.Status == "worked" {
// WORKED means this CALLSIGN is in the log — the question an operator asks
// of a spot ("have I had this station?"), and the one DXHunter answers.
// The entity-level statuses above already say what the ENTITY is worth; an
// entity worked here whose callsign is new is not "worked", it is simply no
// news, which is what "none" means.
if r.WorkedCall {
return "worked"
}
return "none"
@@ -20177,3 +20214,16 @@ func (a *App) tidyLogbookSchema(logbookConn *sql.DB) {
// if this one is ever asked to serve as the logbook again.
db.DropEmptyQSOTable(a.db, filepath.Base(a.dbPath))
}
// countryFor names the DXCC entity of a callsign, or "" when it cannot be
// resolved. Used to enrich a panadapter spot built from a WSJT-X decode, which
// arrives as a bare callsign.
func (a *App) countryFor(call string) string {
if a.dxcc == nil || strings.TrimSpace(call) == "" {
return ""
}
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
return m.Entity.Name
}
return ""
}