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")
}
}
+34 -1
View File
@@ -74,6 +74,15 @@ type Flex struct {
// end up outside the new window — see ZoomPan.
panWindow map[string]panView
// spotSig is what each live spot LOOKS like, and when it was drawn. A
// callsign spotted again with the same frequency, colour and comment does not
// need to be removed and re-added: the panadapter would not change by one
// pixel, and a busy RBN feed re-spots the same station every few seconds. One
// two-minute session sent 2 128 adds and 1 634 removes, 88 of them for a
// single callsign — see SendSpot.
spotSig map[string]string
spotSent map[string]time.Time
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
@@ -205,7 +214,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -859,6 +868,13 @@ func (f *Flex) handleStatus(payload string) {
}
f.mu.Lock()
if removed {
// The radio expired or dropped this spot: forget what it looked
// like, or the next identical spot would be skipped as "already
// drawn" and the station would never come back.
if c, ok := f.spotCall[idx]; ok {
delete(f.spotSig, strings.ToUpper(c))
delete(f.spotSent, strings.ToUpper(c))
}
delete(f.spotIdx, idx)
delete(f.spotCall, idx)
delete(f.spotMode, idx)
@@ -1390,7 +1406,22 @@ func (f *Flex) SendSpot(s SpotInfo) error {
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
// whole Flex goroutine → the radio drops OFFLINE).
upperCall := strings.ToUpper(s.Callsign)
// Unchanged and still fresh → leave it alone.
//
// Refreshed once past half its lifetime, so a long-lived spot still gets its
// timer wound on and never quietly expires off the panadapter; anything that
// actually differs (a new frequency, a status that has changed colour, a
// different comment) is redrawn at once.
sig := fmt.Sprintf("%d|%s|%s|%s|%s", s.FreqHz, s.Mode, s.Color, s.BackgroundColor, s.Comment)
f.mu.Lock()
if prev, ok := f.spotSig[upperCall]; ok && prev == sig {
if at, ok := f.spotSent[upperCall]; ok && time.Since(at) < time.Duration(life)*time.Second/2 {
f.mu.Unlock()
return nil
}
}
f.spotSig[upperCall] = sig
f.spotSent[upperCall] = time.Now()
old, hadOld := f.spotByCall[upperCall]
if hadOld {
delete(f.spotByCall, upperCall)
@@ -1464,6 +1495,8 @@ func (f *Flex) ClearSpots() error {
f.spotCall = map[int]string{}
f.spotMode = map[int]string{}
f.spotFreq = map[int]int64{}
f.spotSig = map[string]string{}
f.spotSent = map[string]time.Time{}
f.spotByCall = map[string]int{}
connected := f.conn != nil
f.mu.Unlock()
+43
View File
@@ -0,0 +1,43 @@
package cat
import (
"testing"
"hamlog/internal/cat/civ"
)
// A CI-V bus can carry a second controller, and the rig answers each of them on
// the same wire. A reply addressed to somebody else must not be taken for ours —
// on an IC-7850 one arrived between a PTT command and its acknowledgement, the
// acknowledgement was never matched, and the failure was handed to JTDX as a rig
// control error.
func TestForeignCIVRepliesAreIgnored(t *testing.T) {
const rig = 0x8E
frames, _ := civ.Scan([]byte{
0xFE, 0xFE, 0x01, rig, 0x1C, 0x00, 0x01, 0xFD, // to controller 01 — not ours
0xFE, 0xFE, civ.AddrController, rig, 0xFB, 0xFD, // to us: the acknowledgement
0xFE, 0xFE, 0x00, rig, 0x00, 0x00, 0x25, 0x09, 0x14, 0x00, 0xFD, // transceive broadcast
})
if len(frames) != 3 {
t.Fatalf("scanned %d frames, want 3", len(frames))
}
var kept []civ.Decoded
for _, f := range frames {
if f.From != rig {
continue
}
if f.To != civ.AddrController && f.To != 0x00 {
continue
}
kept = append(kept, f)
}
if len(kept) != 2 {
t.Fatalf("kept %d frames, want 2 (ours + the broadcast)", len(kept))
}
if kept[0].Cmd != 0xFB {
t.Errorf("the acknowledgement was not the first kept frame: % X", kept[0].Cmd)
}
if kept[1].To != 0x00 {
t.Errorf("the transceive broadcast was dropped")
}
}
+87 -7
View File
@@ -514,8 +514,21 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
err := b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
// Same reasoning as SetPTT: a lost acknowledgement is not a failed command,
// and WSJT-X or JTDX answer a failed set_freq by dropping the link — an
// IC-7850 log shows exactly that at 15:16:15, the disconnect four seconds
// later. The rig is asked what frequency it is on rather than being called a
// failure on the strength of a missing "FB".
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
if got, rerr := b.readFreq(); rerr == nil && got == hz {
applog.Printf("icom: frequency %d Hz not acknowledged, but the rig is on it — treating as done", hz)
return nil
}
return err
}
func (b *IcomSerial) SetMode(mode string) error {
@@ -526,7 +539,16 @@ func (b *IcomSerial) SetMode(mode string) error {
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
return err
// A lost acknowledgement is not a refused mode — see SetPTT. The mode is
// readable, so ask rather than report a failure that would cost the
// digital application its connection.
if !errors.Is(err, errIcomAckLost) {
return err
}
if got, ok := b.readMode(); !ok || got != code {
return err
}
applog.Printf("icom: mode %s not acknowledged, but the rig reports it — treating as done", mode)
}
dataByte := byte(0)
if data {
@@ -569,12 +591,43 @@ func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
}
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
// A lost acknowledgement is NOT proof the rig ignored us, and treating it as one
// is expensive: OpsLog fronts a Hamlib rigctl port, so the failure is handed
// straight to WSJT-X or JTDX, which answer a failed set_ptt with a Rig Control
// Error and drop the link mid-over. An IC-7850 log showed exactly that, three
// times in a session, on a CI-V bus with a second controller corrupting frames.
//
// So when the acknowledgement does not arrive, ASK the rig what it is doing. PTT
// is readable (0x1C 0x00), the answer is unambiguous, and a rig that is keyed
// received the command whatever became of its reply.
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
err := b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
// A GENEROUS window for the verification, not the poll's 150 ms.
//
// The rig has just failed to answer twice in 800 ms, and the reason seen on a
// real IC-7850 is that it was retuning: JTDX moves the transmit frequency and
// keys in the same breath. Asking it again with the short probe timeout would
// fail for exactly the same reason and prove nothing.
keyed, ok := b.readTXWithin(icomPTTVerifyTimeout)
if ok && keyed == on {
applog.Printf("icom: PTT %v not acknowledged, but the rig reports it — treating as done", on)
return nil
}
// Logged either way, because "the fix did not work" and "the rig really did
// not key" look identical from outside and need telling apart.
if ok {
applog.Printf("icom: PTT %v not acknowledged, and the rig reports %v — reporting the failure", on, keyed)
} else {
applog.Printf("icom: PTT %v not acknowledged, and the rig did not answer the check either", on)
}
return err
}
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
@@ -686,6 +739,19 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
if f.From != b.rigAddr {
continue // echo of our own command
}
// Addressed to US, or broadcast.
//
// A CI-V bus can carry a second controller — RS-BA1, wfview, another
// logger — and the rig answers each of them in turn on the same wire.
// Those replies come FROM the rig, so the check above lets them through,
// and one can be taken for the answer to our own command: an IC-7850 log
// showed "FE FE 01 8E …" (to controller 01) arriving between our PTT
// command and its acknowledgement, with bus collisions around it.
// Transceive broadcasts (to 0x00) stay welcome — that is how the rig
// announces a knob turn.
if f.To != civ.AddrController && f.To != 0x00 {
continue
}
if f.Cmd == civ.CmdScope {
b.route(b.specCh, f)
continue
@@ -1273,16 +1339,30 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
func (b *IcomSerial) readTX() bool {
on, _ := b.readTXWithin(icomDSPTimeout)
return on
}
// icomPTTVerifyTimeout is how long the PTT check waits. Long, deliberately: it
// runs only after a command was already given up on, so it costs nothing in the
// normal case and everything depends on it in the abnormal one.
const icomPTTVerifyTimeout = 700 * time.Millisecond
// readTXWithin reads the transmit state, reporting whether the rig ANSWERED.
//
// The two are different questions and the caller usually needs both: "not
// transmitting" and "did not say" are the same bool but not the same fact.
func (b *IcomSerial) readTXWithin(timeout time.Duration) (on bool, answered bool) {
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
return false
return false, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
f, err := b.recv(timeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
})
if err != nil {
return false
return false, false
}
return f.Data[1] != 0
return f.Data[1] != 0, true
}
// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.