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
+69 -4
View File
@@ -20,9 +20,15 @@ var (
mu sync.Mutex
file *os.File
path string
written int64 // bytes in the CURRENT file, for the size check in Printf
crashFile *os.File // kept open so the runtime can write a crash traceback to it
)
// maxLogBytes is where the file rolls over, at startup AND while running.
// A variable, not a constant, so the rotation can be exercised in a test without
// writing ten megabytes to do it.
var maxLogBytes int64 = 10 * 1024 * 1024
// Init opens (creates) the log file in dataDir. On rotation we truncate
// at startup if the file is too big; for now it's a single file, no
// rolling — the volume is low (a few KB per session).
@@ -47,12 +53,12 @@ func Init(dataDir string) (string, error) {
_ = os.Rename(oldLog, logPath)
}
}
// Rotate (don't delete) once the file grows past ~10MB: rename it to
// Rotate (don't delete) once the file grows past maxLogBytes: rename it to
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
// to erase exactly the diagnostics we needed when a user reported an issue from
// the session that just ended. One generation kept — enough, without unbounded growth.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
_ = os.Remove(logPath + ".1") // drop the older generation
if fi, err := os.Stat(logPath); err == nil && fi.Size() > maxLogBytes {
_ = os.Remove(logPath + ".1") // drop the older generation
if err := os.Rename(logPath, logPath+".1"); err != nil {
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
}
@@ -96,13 +102,57 @@ func Printf(format string, args ...any) {
msg = msg[:len(msg)-1]
}
if file != nil {
fmt.Fprintf(file, "%s %s\n", stamp, msg)
n, _ := fmt.Fprintf(file, "%s %s\n", stamp, msg)
written += int64(n)
rotateIfBigLocked()
}
// Also dump to stderr in case the binary was launched with a console
// attached (wails dev, custom build).
fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
}
// rotateIfBigLocked rolls the file over mid-session once it passes maxLogBytes.
//
// The check used to happen at STARTUP only, on the reasonable belief that a
// session writes a few kilobytes. The CI-V wire trace disproved it: left on for
// a day it wrote 416 MB, and nothing would have stopped it before the disk did.
// A log that has to be rotated by quitting the program is not a bound.
//
// Same one-generation scheme as the startup rotation, so the previous stretch
// survives — which is the half an operator usually needs, the part just before
// the thing they are reporting.
//
// Caller holds mu.
func rotateIfBigLocked() {
if written < maxLogBytes || file == nil || path == "" {
return
}
fmt.Fprintf(file, "%s ── log reached %d MB, rotating to %s.1 ──\n",
time.Now().Format("15:04:05.000"), maxLogBytes/(1024*1024), filepath.Base(path))
_ = file.Close()
file = nil
_ = os.Remove(path + ".1")
if err := os.Rename(path, path+".1"); err != nil {
// Rename refused (the file is held open elsewhere, a virus scanner): keep
// writing to the old handle rather than losing the log entirely, and try
// again in another maxLogBytes rather than on every single line.
f, oerr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if oerr == nil {
file = f
}
written = 0
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return // nothing more to be done; the next Printf simply writes nowhere
}
file = f
written = 0
fmt.Fprintf(file, "%s ── continued from %s.1 ──\n",
time.Now().Format("15:04:05.000"), filepath.Base(path))
}
// Path returns where the file is so the UI can surface it.
func Path() string {
mu.Lock()
@@ -118,4 +168,19 @@ func Close() {
_ = file.Close()
file = nil
}
// The crash file is held open for the whole run so the runtime can dump a
// traceback into it without allocating; on the way out it has to be released
// like any other handle, or the data directory cannot be moved or removed.
if crashFile != nil {
// The RUNTIME holds its own duplicate of this handle (SetCrashOutput), so
// closing ours releases nothing on Windows — the data directory stays
// locked. Hand the runtime a nil first. Anything fatal in the last moments
// of shutdown is no longer captured, which is the right trade at a point
// where the databases are already closed.
_ = debug.SetCrashOutput(nil, debug.CrashOptions{})
_ = crashFile.Close()
crashFile = nil
}
path = ""
written = 0
}
+49
View File
@@ -0,0 +1,49 @@
package applog
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The log has to be bounded WHILE RUNNING, not only at startup. The CI-V wire
// trace left on for a day wrote 416 MB, and nothing would have stopped it
// before the disk did.
func TestRotatesMidSession(t *testing.T) {
orig := maxLogBytes
maxLogBytes = 8 * 1024 // small, so the test costs milliseconds
defer func() { maxLogBytes = orig }()
dir := t.TempDir()
p, err := Init(dir)
if err != nil {
t.Fatal(err)
}
defer Close()
line := strings.Repeat("x", 256)
for i := 0; i < 100; i++ { // ~25 KB: several times the limit
Printf("%s", line)
}
if _, err := os.Stat(p + ".1"); err != nil {
t.Fatalf("no previous generation kept: %v", err)
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Size() > maxLogBytes {
t.Fatalf("current log is %d bytes, over the %d limit — it did not roll over", fi.Size(), maxLogBytes)
}
// The new file says where the rest went: a log that starts mid-sentence with
// no explanation is how an operator concludes the app lost its history.
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "continued from "+filepath.Base(p)+".1") {
t.Error("the new file does not point at the one it continues")
}
}